Merge remote-tracking branch 'upstream/main' into hashtags_release
This commit is contained in:
@@ -14,10 +14,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Set up JDK 11
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v1
|
uses: actions/setup-java@v1
|
||||||
with:
|
with:
|
||||||
java-version: 11
|
java-version: 17
|
||||||
|
|
||||||
- name: Cache gradle
|
- name: Cache gradle
|
||||||
uses: actions/cache@v1
|
uses: actions/cache@v1
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Set up JDK 11
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v1
|
uses: actions/setup-java@v1
|
||||||
with:
|
with:
|
||||||
java-version: 11
|
java-version: 17
|
||||||
|
|
||||||
- name: Cache gradle
|
- name: Cache gradle
|
||||||
uses: actions/cache@v1
|
uses: actions/cache@v1
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ Lastly, the user's account information (priv key/pub key) is stored in the Andro
|
|||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
Make sure to have the following pre-requisites installed:
|
Make sure to have the following pre-requisites installed:
|
||||||
1. Java 11
|
1. Java 17
|
||||||
2. Android Studio
|
2. Android Studio
|
||||||
3. Android 8.0+ Phone or Emulation setup
|
3. Android 8.0+ Phone or Emulation setup
|
||||||
|
|
||||||
|
|||||||
+13
-10
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId "com.vitorpamplona.amethyst"
|
applicationId "com.vitorpamplona.amethyst"
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 33
|
targetSdk 33
|
||||||
versionCode 118
|
versionCode 123
|
||||||
versionName "0.32.3"
|
versionName "0.34.1"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@@ -57,12 +57,12 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_11
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
targetCompatibility JavaVersion.VERSION_11
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = '11'
|
jvmTarget = '17'
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
@@ -78,10 +78,14 @@ android {
|
|||||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lint {
|
||||||
|
disable 'MissingTranslation'
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'androidx.core:core-ktx:1.9.0'
|
implementation 'androidx.core:core-ktx:1.10.0'
|
||||||
implementation 'androidx.activity:activity-compose:1.7.0-beta01'
|
implementation 'androidx.activity:activity-compose:1.7.0-beta01'
|
||||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||||
@@ -142,15 +146,14 @@ dependencies {
|
|||||||
// view svgs
|
// view svgs
|
||||||
implementation "io.coil-kt:coil-svg:$coil_version"
|
implementation "io.coil-kt:coil-svg:$coil_version"
|
||||||
|
|
||||||
|
// create blurhash
|
||||||
|
implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
|
||||||
|
|
||||||
// Rendering clickable text
|
// Rendering clickable text
|
||||||
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
|
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
|
||||||
// Permission to upload pictures:
|
// Permission to upload pictures:
|
||||||
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
||||||
|
|
||||||
// tabs for user profiles
|
|
||||||
implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
|
|
||||||
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
|
|
||||||
|
|
||||||
// Parses URLs from Text:
|
// Parses URLs from Text:
|
||||||
implementation "io.github.url-detector:url-detector:0.1.23"
|
implementation "io.github.url-detector:url-detector:0.1.23"
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ object VideoCache {
|
|||||||
|
|
||||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||||
|
|
||||||
fun get(context: Context): CacheDataSource.Factory {
|
fun init(context: Context) {
|
||||||
if (!this::simpleCache.isInitialized) {
|
if (!this::simpleCache.isInitialized) {
|
||||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||||
|
|
||||||
@@ -35,7 +35,9 @@ object VideoCache {
|
|||||||
)
|
)
|
||||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(): CacheDataSource.Factory {
|
||||||
return cacheDataSourceFactory
|
return cacheDataSourceFactory
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model
|
|||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import androidx.core.os.ConfigurationCompat
|
import androidx.core.os.ConfigurationCompat
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
|
import com.vitorpamplona.amethyst.service.FileHeader
|
||||||
import com.vitorpamplona.amethyst.service.model.*
|
import com.vitorpamplona.amethyst.service.model.*
|
||||||
import com.vitorpamplona.amethyst.service.relays.Client
|
import com.vitorpamplona.amethyst.service.relays.Client
|
||||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||||
@@ -54,8 +55,14 @@ class Account(
|
|||||||
val liveLanguages: AccountLiveData = AccountLiveData(this)
|
val liveLanguages: AccountLiveData = AccountLiveData(this)
|
||||||
val saveable: AccountLiveData = AccountLiveData(this)
|
val saveable: AccountLiveData = AccountLiveData(this)
|
||||||
|
|
||||||
|
var userProfileCache: User? = null
|
||||||
|
|
||||||
fun userProfile(): User {
|
fun userProfile(): User {
|
||||||
return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
|
return userProfileCache ?: run {
|
||||||
|
val myUser: User = LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
|
||||||
|
userProfileCache = myUser
|
||||||
|
myUser
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun followingChannels(): List<Channel> {
|
fun followingChannels(): List<Channel> {
|
||||||
@@ -351,6 +358,25 @@ class Account(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun sendHeader(headerInfo: FileHeader): Note? {
|
||||||
|
if (!isWriteable()) return null
|
||||||
|
|
||||||
|
val signedEvent = FileHeaderEvent.create(
|
||||||
|
url = headerInfo.url,
|
||||||
|
mimeType = headerInfo.mimeType,
|
||||||
|
hash = headerInfo.hash,
|
||||||
|
size = headerInfo.size.toString(),
|
||||||
|
blurhash = headerInfo.blurHash,
|
||||||
|
description = headerInfo.description,
|
||||||
|
privateKey = loggedIn.privKey!!
|
||||||
|
)
|
||||||
|
|
||||||
|
Client.send(signedEvent)
|
||||||
|
LocalCache.consume(signedEvent)
|
||||||
|
|
||||||
|
return LocalCache.notes[signedEvent.id]
|
||||||
|
}
|
||||||
|
|
||||||
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null) {
|
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null) {
|
||||||
if (!isWriteable()) return
|
if (!isWriteable()) return
|
||||||
|
|
||||||
@@ -722,11 +748,11 @@ class Account(
|
|||||||
fun isHidden(userHex: String) = userHex in hiddenUsers || userHex in transientHiddenUsers
|
fun isHidden(userHex: String) = userHex in hiddenUsers || userHex in transientHiddenUsers
|
||||||
|
|
||||||
fun followingKeySet(): Set<HexKey> {
|
fun followingKeySet(): Set<HexKey> {
|
||||||
return userProfile().cachedFollowingKeySet() ?: emptySet()
|
return userProfile().cachedFollowingKeySet()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun followingTagSet(): Set<HexKey> {
|
fun followingTagSet(): Set<HexKey> {
|
||||||
return userProfile().cachedFollowingTagSet() ?: emptySet()
|
return userProfile().cachedFollowingTagSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isAcceptable(user: User): Boolean {
|
fun isAcceptable(user: User): Boolean {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature
|
|||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.vitorpamplona.amethyst.service.model.*
|
import com.vitorpamplona.amethyst.service.model.*
|
||||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||||
import fr.acinq.secp256k1.Hex
|
import fr.acinq.secp256k1.Hex
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import nostr.postr.toNpub
|
import nostr.postr.toNpub
|
||||||
@@ -189,7 +189,7 @@ object LocalCache {
|
|||||||
it.addReply(note)
|
it.addReply(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshObservers()
|
refreshObservers(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun consume(event: LongTextNoteEvent, relay: Relay?) {
|
fun consume(event: LongTextNoteEvent, relay: Relay?) {
|
||||||
@@ -218,7 +218,7 @@ object LocalCache {
|
|||||||
|
|
||||||
author.addNote(note)
|
author.addNote(note)
|
||||||
|
|
||||||
refreshObservers()
|
refreshObservers(note)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +255,7 @@ object LocalCache {
|
|||||||
it.addReply(note)
|
it.addReply(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshObservers()
|
refreshObservers(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun consume(event: BadgeDefinitionEvent) {
|
fun consume(event: BadgeDefinitionEvent) {
|
||||||
@@ -268,7 +268,7 @@ object LocalCache {
|
|||||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||||
note.loadEvent(event, author, emptyList<Note>())
|
note.loadEvent(event, author, emptyList<Note>())
|
||||||
|
|
||||||
refreshObservers()
|
refreshObservers(note)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,8 +286,6 @@ object LocalCache {
|
|||||||
note.loadEvent(event, author, replyTo)
|
note.loadEvent(event, author, replyTo)
|
||||||
|
|
||||||
author.updateAcceptedBadges(note)
|
author.updateAcceptedBadges(note)
|
||||||
|
|
||||||
refreshObservers()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +307,7 @@ object LocalCache {
|
|||||||
it.addReply(note)
|
it.addReply(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshObservers()
|
refreshObservers(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNUSED_PARAMETER")
|
@Suppress("UNUSED_PARAMETER")
|
||||||
@@ -353,7 +351,7 @@ object LocalCache {
|
|||||||
recipient.addMessage(author, note)
|
recipient.addMessage(author, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshObservers()
|
refreshObservers(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun consume(event: DeletionEvent) {
|
fun consume(event: DeletionEvent) {
|
||||||
@@ -401,7 +399,7 @@ object LocalCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (deletedAtLeastOne) {
|
if (deletedAtLeastOne) {
|
||||||
live.invalidateData()
|
// refreshObservers()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +425,7 @@ object LocalCache {
|
|||||||
it.addBoost(note)
|
it.addBoost(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshObservers()
|
refreshObservers(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun consume(event: ReactionEvent) {
|
fun consume(event: ReactionEvent) {
|
||||||
@@ -465,6 +463,8 @@ object LocalCache {
|
|||||||
it.addReport(note)
|
it.addReport(note)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshObservers(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun consume(event: ReportEvent, relay: Relay?) {
|
fun consume(event: ReportEvent, relay: Relay?) {
|
||||||
@@ -495,6 +495,8 @@ object LocalCache {
|
|||||||
repliesTo.forEach {
|
repliesTo.forEach {
|
||||||
it.addReport(note)
|
it.addReport(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshObservers(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun consume(event: ChannelCreateEvent) {
|
fun consume(event: ChannelCreateEvent) {
|
||||||
@@ -511,7 +513,7 @@ object LocalCache {
|
|||||||
oldChannel.addNote(note)
|
oldChannel.addNote(note)
|
||||||
note.loadEvent(event, author, emptyList())
|
note.loadEvent(event, author, emptyList())
|
||||||
|
|
||||||
refreshObservers()
|
refreshObservers(note)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,7 +533,7 @@ object LocalCache {
|
|||||||
oldChannel.addNote(note)
|
oldChannel.addNote(note)
|
||||||
note.loadEvent(event, author, emptyList())
|
note.loadEvent(event, author, emptyList())
|
||||||
|
|
||||||
refreshObservers()
|
refreshObservers(note)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||||
@@ -578,7 +580,7 @@ object LocalCache {
|
|||||||
it.addReply(note)
|
it.addReply(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshObservers()
|
refreshObservers(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNUSED_PARAMETER")
|
@Suppress("UNUSED_PARAMETER")
|
||||||
@@ -595,7 +597,7 @@ object LocalCache {
|
|||||||
// Already processed this event.
|
// Already processed this event.
|
||||||
if (note.event != null) return
|
if (note.event != null) return
|
||||||
|
|
||||||
val zapRequest = event.containedPost()?.id?.let { getOrCreateNote(it) }
|
val zapRequest = event.zapRequest?.id?.let { getOrCreateNote(it) }
|
||||||
|
|
||||||
val author = getOrCreateUser(event.pubKey)
|
val author = getOrCreateUser(event.pubKey)
|
||||||
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
|
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
|
||||||
@@ -620,6 +622,8 @@ object LocalCache {
|
|||||||
mentions.forEach {
|
mentions.forEach {
|
||||||
it.addZap(zapRequest, note)
|
it.addZap(zapRequest, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshObservers(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun consume(event: LnZapRequestEvent) {
|
fun consume(event: LnZapRequestEvent) {
|
||||||
@@ -643,6 +647,21 @@ object LocalCache {
|
|||||||
mentions.forEach {
|
mentions.forEach {
|
||||||
it.addZap(note, null)
|
it.addZap(note, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshObservers(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consume(event: FileHeaderEvent) {
|
||||||
|
val note = getOrCreateNote(event.id)
|
||||||
|
|
||||||
|
// Already processed this event.
|
||||||
|
if (note.event != null) return
|
||||||
|
|
||||||
|
val author = getOrCreateUser(event.pubKey)
|
||||||
|
|
||||||
|
note.loadEvent(event, author, emptyList())
|
||||||
|
|
||||||
|
refreshObservers(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findUsersStartingWith(username: String): List<User> {
|
fun findUsersStartingWith(username: String): List<User> {
|
||||||
@@ -694,11 +713,6 @@ object LocalCache {
|
|||||||
notes.remove(it.idHex)
|
notes.remove(it.idHex)
|
||||||
// Doesn't need to clean up the replies and mentions.. Too small to matter.
|
// Doesn't need to clean up the replies and mentions.. Too small to matter.
|
||||||
|
|
||||||
// reverts the add
|
|
||||||
val mentions =
|
|
||||||
it.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
|
|
||||||
?.mapNotNull { checkGetOrCreateUser(it) }
|
|
||||||
|
|
||||||
// Counts the replies
|
// Counts the replies
|
||||||
it.replyTo?.forEach { _ ->
|
it.replyTo?.forEach { _ ->
|
||||||
it.removeReply(it)
|
it.removeReply(it)
|
||||||
@@ -749,30 +763,23 @@ object LocalCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Observers line up here.
|
// Observers line up here.
|
||||||
val live: LocalCacheLiveData = LocalCacheLiveData(this)
|
val live: LocalCacheLiveData = LocalCacheLiveData()
|
||||||
|
|
||||||
private fun refreshObservers() {
|
private fun refreshObservers(newNote: Note) {
|
||||||
live.invalidateData()
|
live.invalidateData(newNote)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalCacheLiveData(val cache: LocalCache) :
|
class LocalCacheLiveData : LiveData<Set<Note>>(setOf<Note>()) {
|
||||||
LiveData<LocalCacheState>(LocalCacheState(cache)) {
|
|
||||||
|
|
||||||
// Refreshes observers in batches.
|
// Refreshes observers in batches.
|
||||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
private val bundler = BundledInsert<Note>(300, Dispatchers.Main)
|
||||||
if (hasActiveObservers()) {
|
|
||||||
refresh()
|
fun invalidateData(newNote: Note) {
|
||||||
|
bundler.invalidateList(newNote) { bundledNewNotes ->
|
||||||
|
if (hasActiveObservers()) {
|
||||||
|
postValue(bundledNewNotes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun invalidateData() {
|
|
||||||
bundler.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refresh() {
|
|
||||||
postValue(LocalCacheState(cache))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalCacheState(val cache: LocalCache)
|
|
||||||
|
|||||||
@@ -53,13 +53,14 @@ open class Note(val idHex: String) {
|
|||||||
open fun idNote() = id().toNote()
|
open fun idNote() = id().toNote()
|
||||||
open fun idDisplayNote() = idNote().toShortenHex()
|
open fun idDisplayNote() = idNote().toShortenHex()
|
||||||
|
|
||||||
fun channel(): Channel? {
|
fun channelHex(): HexKey? {
|
||||||
val channelHex =
|
return (event as? ChannelMessageEvent)?.channel()
|
||||||
(event as? ChannelMessageEvent)?.channel()
|
?: (event as? ChannelMetadataEvent)?.channel()
|
||||||
?: (event as? ChannelMetadataEvent)?.channel()
|
?: (event as? ChannelCreateEvent)?.id
|
||||||
?: (event as? ChannelCreateEvent)?.id
|
}
|
||||||
|
|
||||||
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
|
fun channel(): Channel? {
|
||||||
|
return channelHex()?.let { LocalCache.checkGetOrCreateChannel(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun address(): ATag? = null
|
open fun address(): ATag? = null
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ class User(val pubkeyHex: String) {
|
|||||||
fun hasSentMessagesTo(user: User?): Boolean {
|
fun hasSentMessagesTo(user: User?): Boolean {
|
||||||
val messagesToUser = privateChatrooms[user] ?: return false
|
val messagesToUser = privateChatrooms[user] ?: return false
|
||||||
|
|
||||||
return messagesToUser.roomMessages.any { this == it.author }
|
return messagesToUser.roomMessages.any { this === it.author }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
|
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.withSign
|
||||||
|
|
||||||
|
object BlurHashDecoder {
|
||||||
|
|
||||||
|
// cache Math.cos() calculations to improve performance.
|
||||||
|
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
|
||||||
|
// the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
|
||||||
|
private val cacheCosinesX = HashMap<Int, DoubleArray>()
|
||||||
|
private val cacheCosinesY = HashMap<Int, DoubleArray>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear calculations stored in memory cache.
|
||||||
|
* The cache is not big, but will increase when many image sizes are used,
|
||||||
|
* if the app needs memory it is recommended to clear it.
|
||||||
|
*/
|
||||||
|
fun clearCache() {
|
||||||
|
cacheCosinesX.clear()
|
||||||
|
cacheCosinesY.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns width/height
|
||||||
|
*/
|
||||||
|
fun aspectRatio(blurHash: String?): Float? {
|
||||||
|
if (blurHash == null || blurHash.length < 6) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val numCompEnc = decode83(blurHash, 0, 1)
|
||||||
|
val numCompX = (numCompEnc % 9) + 1
|
||||||
|
val numCompY = (numCompEnc / 9) + 1
|
||||||
|
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return numCompX.toFloat() / numCompY.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a blur hash into a new bitmap.
|
||||||
|
*
|
||||||
|
* @param useCache use in memory cache for the calculated math, reused by images with same size.
|
||||||
|
* if the cache does not exist yet it will be created and populated with new calculations.
|
||||||
|
* By default it is true.
|
||||||
|
*/
|
||||||
|
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
|
||||||
|
if (blurHash == null || blurHash.length < 6) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val numCompEnc = decode83(blurHash, 0, 1)
|
||||||
|
val numCompX = (numCompEnc % 9) + 1
|
||||||
|
val numCompY = (numCompEnc / 9) + 1
|
||||||
|
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val maxAcEnc = decode83(blurHash, 1, 2)
|
||||||
|
val maxAc = (maxAcEnc + 1) / 166f
|
||||||
|
val colors = Array(numCompX * numCompY) { i ->
|
||||||
|
if (i == 0) {
|
||||||
|
val colorEnc = decode83(blurHash, 2, 6)
|
||||||
|
decodeDc(colorEnc)
|
||||||
|
} else {
|
||||||
|
val from = 4 + i * 2
|
||||||
|
val colorEnc = decode83(blurHash, from, from + 2)
|
||||||
|
decodeAc(colorEnc, maxAc * punch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int {
|
||||||
|
var result = 0
|
||||||
|
for (i in from until to) {
|
||||||
|
val index = charMap[str[i]] ?: -1
|
||||||
|
if (index != -1) {
|
||||||
|
result = result * 83 + index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeDc(colorEnc: Int): FloatArray {
|
||||||
|
val r = colorEnc shr 16
|
||||||
|
val g = (colorEnc shr 8) and 255
|
||||||
|
val b = colorEnc and 255
|
||||||
|
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun srgbToLinear(colorEnc: Int): Float {
|
||||||
|
val v = colorEnc / 255f
|
||||||
|
return if (v <= 0.04045f) {
|
||||||
|
(v / 12.92f)
|
||||||
|
} else {
|
||||||
|
((v + 0.055f) / 1.055f).pow(2.4f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
|
||||||
|
val r = value / (19 * 19)
|
||||||
|
val g = (value / 19) % 19
|
||||||
|
val b = value % 19
|
||||||
|
return floatArrayOf(
|
||||||
|
signedPow2((r - 9) / 9.0f) * maxAc,
|
||||||
|
signedPow2((g - 9) / 9.0f) * maxAc,
|
||||||
|
signedPow2((b - 9) / 9.0f) * maxAc
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun signedPow2(value: Float) = value.pow(2f).withSign(value)
|
||||||
|
|
||||||
|
private fun composeBitmap(
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
numCompX: Int,
|
||||||
|
numCompY: Int,
|
||||||
|
colors: Array<FloatArray>,
|
||||||
|
useCache: Boolean
|
||||||
|
): Bitmap {
|
||||||
|
// use an array for better performance when writing pixel colors
|
||||||
|
val imageArray = IntArray(width * height)
|
||||||
|
val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
|
||||||
|
val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
|
||||||
|
val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
|
||||||
|
val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
|
||||||
|
for (y in 0 until height) {
|
||||||
|
for (x in 0 until width) {
|
||||||
|
var r = 0f
|
||||||
|
var g = 0f
|
||||||
|
var b = 0f
|
||||||
|
for (j in 0 until numCompY) {
|
||||||
|
for (i in 0 until numCompX) {
|
||||||
|
val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width)
|
||||||
|
val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height)
|
||||||
|
val basis = (cosX * cosY).toFloat()
|
||||||
|
val color = colors[j * numCompX + i]
|
||||||
|
r += color[0] * basis
|
||||||
|
g += color[1] * basis
|
||||||
|
b += color[2] * basis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when {
|
||||||
|
calculate -> {
|
||||||
|
DoubleArray(height * numCompY).also {
|
||||||
|
cacheCosinesY[height * numCompY] = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
cacheCosinesY[height * numCompY]!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when {
|
||||||
|
calculate -> {
|
||||||
|
DoubleArray(width * numCompX).also {
|
||||||
|
cacheCosinesX[width * numCompX] = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> cacheCosinesX[width * numCompX]!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DoubleArray.getCos(
|
||||||
|
calculate: Boolean,
|
||||||
|
x: Int,
|
||||||
|
numComp: Int,
|
||||||
|
y: Int,
|
||||||
|
size: Int
|
||||||
|
): Double {
|
||||||
|
if (calculate) {
|
||||||
|
this[x + numComp * y] = cos(Math.PI * y * x / size)
|
||||||
|
}
|
||||||
|
return this[x + numComp * y]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun linearToSrgb(value: Float): Int {
|
||||||
|
val v = value.coerceIn(0f, 1f)
|
||||||
|
return if (v <= 0.0031308f) {
|
||||||
|
(v * 12.92f * 255f + 0.5f).toInt()
|
||||||
|
} else {
|
||||||
|
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val charMap = listOf(
|
||||||
|
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
|
||||||
|
'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
|
||||||
|
'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
|
||||||
|
'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',',
|
||||||
|
'-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
|
||||||
|
)
|
||||||
|
.mapIndexed { i, c -> c to i }
|
||||||
|
.toMap()
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.decode.DataSource
|
||||||
|
import coil.fetch.DrawableResult
|
||||||
|
import coil.fetch.FetchResult
|
||||||
|
import coil.fetch.Fetcher
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.request.Options
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class BlurHashFetcher(
|
||||||
|
private val options: Options,
|
||||||
|
private val data: Uri
|
||||||
|
) : Fetcher {
|
||||||
|
|
||||||
|
override suspend fun fetch(): FetchResult {
|
||||||
|
val encodedHash = data.toString().removePrefix("bluehash:")
|
||||||
|
val hash = URLDecoder.decode(encodedHash, "utf-8")
|
||||||
|
|
||||||
|
val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f
|
||||||
|
|
||||||
|
val preferredWidth = 100
|
||||||
|
|
||||||
|
val bitmap = BlurHashDecoder.decode(
|
||||||
|
hash,
|
||||||
|
preferredWidth,
|
||||||
|
(preferredWidth * (1 / aspectRatio)).roundToInt()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (bitmap == null) {
|
||||||
|
throw Exception("Unable to convert Bluehash $hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
return DrawableResult(
|
||||||
|
drawable = bitmap.toDrawable(options.context.resources),
|
||||||
|
isSampled = false,
|
||||||
|
dataSource = DataSource.MEMORY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
object Factory : Fetcher.Factory<Uri> {
|
||||||
|
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||||
|
return BlurHashFetcher(options, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object BlurHashRequester {
|
||||||
|
fun imageRequest(context: Context, message: String): ImageRequest {
|
||||||
|
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||||
|
|
||||||
|
return ImageRequest
|
||||||
|
.Builder(context)
|
||||||
|
.data("bluehash:$encodedMessage")
|
||||||
|
.fetcherFactory(BlurHashFetcher.Factory)
|
||||||
|
.crossfade(100)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.Log
|
||||||
|
import com.vitorpamplona.amethyst.model.toHexKey
|
||||||
|
import io.trbl.blurhash.BlurHash
|
||||||
|
import java.net.URL
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class FileHeader(
|
||||||
|
val url: String,
|
||||||
|
val mimeType: String?,
|
||||||
|
val hash: String,
|
||||||
|
val size: Int,
|
||||||
|
val blurHash: String?,
|
||||||
|
val description: String? = null
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun prepare(fileUrl: String, mimeType: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||||
|
try {
|
||||||
|
val imageData = URL(fileUrl).readBytes()
|
||||||
|
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||||
|
|
||||||
|
val hash = sha256.digest(imageData).toHexKey()
|
||||||
|
val size = imageData.size
|
||||||
|
|
||||||
|
val blurHash = if (mimeType?.startsWith("image/") == true) {
|
||||||
|
val opt = BitmapFactory.Options()
|
||||||
|
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||||
|
val mBitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size, opt)
|
||||||
|
|
||||||
|
val intArray = IntArray(mBitmap.width * mBitmap.height)
|
||||||
|
mBitmap.getPixels(
|
||||||
|
intArray,
|
||||||
|
0,
|
||||||
|
mBitmap.width,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
mBitmap.width,
|
||||||
|
mBitmap.height
|
||||||
|
)
|
||||||
|
|
||||||
|
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
|
||||||
|
|
||||||
|
if (aspectRatio > 1) {
|
||||||
|
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt())
|
||||||
|
} else if (aspectRatio < 1) {
|
||||||
|
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9)
|
||||||
|
} else {
|
||||||
|
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, ""))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||||
|
onError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,8 +75,9 @@ abstract class NostrDataSource(val debugName: String) {
|
|||||||
is ContactListEvent -> LocalCache.consume(event)
|
is ContactListEvent -> LocalCache.consume(event)
|
||||||
is DeletionEvent -> LocalCache.consume(event)
|
is DeletionEvent -> LocalCache.consume(event)
|
||||||
|
|
||||||
|
is FileHeaderEvent -> LocalCache.consume(event)
|
||||||
is LnZapEvent -> {
|
is LnZapEvent -> {
|
||||||
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
|
event.zapRequest?.let { onEvent(it, subscriptionId, relay) }
|
||||||
LocalCache.consume(event)
|
LocalCache.consume(event)
|
||||||
}
|
}
|
||||||
is LnZapRequestEvent -> LocalCache.consume(event)
|
is LnZapRequestEvent -> LocalCache.consume(event)
|
||||||
@@ -154,7 +155,7 @@ abstract class NostrDataSource(val debugName: String) {
|
|||||||
|
|
||||||
// Refreshes observers in batches.
|
// Refreshes observers in batches.
|
||||||
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
||||||
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||||
|
|
||||||
// adds the time to perform the refresh into this delay
|
// adds the time to perform the refresh into this delay
|
||||||
// holding off new updates in case of heavy refresh routines.
|
// holding off new updates in case of heavy refresh routines.
|
||||||
|
|||||||
@@ -17,7 +17,14 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
|
|||||||
return TypedFilter(
|
return TypedFilter(
|
||||||
types = FeedType.values().toSet(),
|
types = FeedType.values().toSet(),
|
||||||
filter = JsonFilter(
|
filter = JsonFilter(
|
||||||
tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())),
|
tags = mapOf(
|
||||||
|
"t" to listOf(
|
||||||
|
hashToLoad,
|
||||||
|
hashToLoad.lowercase(),
|
||||||
|
hashToLoad.uppercase(),
|
||||||
|
hashToLoad.capitalize()
|
||||||
|
)
|
||||||
|
),
|
||||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
|
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
|
||||||
limit = 200
|
limit = 200
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime
|
|||||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||||
import java.util.Date
|
|
||||||
|
|
||||||
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||||
private var eventsToWatch = setOf<Note>()
|
private var eventsToWatch = setOf<Note>()
|
||||||
@@ -20,8 +19,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val now = Date().time / 1000
|
|
||||||
|
|
||||||
return addressesToWatch.mapNotNull {
|
return addressesToWatch.mapNotNull {
|
||||||
it.address()?.let { aTag ->
|
it.address()?.let { aTag ->
|
||||||
TypedFilter(
|
TypedFilter(
|
||||||
@@ -49,8 +46,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val now = Date().time / 1000
|
|
||||||
|
|
||||||
return addressesToWatch.mapNotNull {
|
return addressesToWatch.mapNotNull {
|
||||||
it.address()?.let { aTag ->
|
it.address()?.let { aTag ->
|
||||||
TypedFilter(
|
TypedFilter(
|
||||||
@@ -116,9 +111,12 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
|||||||
types = FeedType.values().toSet(),
|
types = FeedType.values().toSet(),
|
||||||
filter = JsonFilter(
|
filter = JsonFilter(
|
||||||
kinds = listOf(
|
kinds = listOf(
|
||||||
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
|
TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind,
|
||||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
|
ReactionEvent.kind, RepostEvent.kind,
|
||||||
PollNoteEvent.kind, PrivateDmEvent.kind
|
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||||
|
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
|
||||||
|
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
|
||||||
|
PrivateDmEvent.kind, FileHeaderEvent.kind
|
||||||
),
|
),
|
||||||
ids = interestedEvents.toList()
|
ids = interestedEvents.toList()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val loadEventsChannel = requestNewChannel() { eoseTime, relay ->
|
val loadEventsChannel = requestNewChannel() { _, _ ->
|
||||||
// Many relays operate with limits in the amount of filters.
|
// Many relays operate with limits in the amount of filters.
|
||||||
// As information comes, the filters will be rotated to get more data.
|
// As information comes, the filters will be rotated to get more data.
|
||||||
invalidateFilters()
|
invalidateFilters()
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ open class Event(
|
|||||||
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
|
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
|
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
|
||||||
|
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
|
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service.model
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.model.HexKey
|
||||||
|
import com.vitorpamplona.amethyst.model.toHexKey
|
||||||
|
import nostr.postr.Utils
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class FileHeaderEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: List<List<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey
|
||||||
|
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||||
|
|
||||||
|
fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1)
|
||||||
|
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
|
||||||
|
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
|
||||||
|
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
|
||||||
|
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
|
||||||
|
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
|
||||||
|
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
|
||||||
|
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val kind = 1063
|
||||||
|
|
||||||
|
private const val URL = "url"
|
||||||
|
private const val ENCRYPTION_KEY = "aes-256-gcm"
|
||||||
|
private const val MIME_TYPE = "m"
|
||||||
|
private const val FILE_SIZE = "size"
|
||||||
|
private const val HASH = "x"
|
||||||
|
private const val MAGNET_URI = "magnet"
|
||||||
|
private const val TORRENT_INFOHASH = "i"
|
||||||
|
private const val BLUR_HASH = "blurhash"
|
||||||
|
|
||||||
|
fun create(
|
||||||
|
url: String,
|
||||||
|
mimeType: String? = null,
|
||||||
|
description: String? = null,
|
||||||
|
hash: String? = null,
|
||||||
|
size: String? = null,
|
||||||
|
blurhash: String? = null,
|
||||||
|
magnetURI: String? = null,
|
||||||
|
torrentInfoHash: String? = null,
|
||||||
|
encryptionKey: AESGCM? = null,
|
||||||
|
privateKey: ByteArray,
|
||||||
|
createdAt: Long = Date().time / 1000
|
||||||
|
): FileHeaderEvent {
|
||||||
|
var tags = listOfNotNull(
|
||||||
|
listOf(URL, url),
|
||||||
|
mimeType?.let { listOf(MIME_TYPE, mimeType) },
|
||||||
|
hash?.let { listOf(HASH, it) },
|
||||||
|
size?.let { listOf(FILE_SIZE, it) },
|
||||||
|
blurhash?.let { listOf(BLUR_HASH, it) },
|
||||||
|
magnetURI?.let { listOf(MAGNET_URI, it) },
|
||||||
|
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
|
||||||
|
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
|
||||||
|
)
|
||||||
|
|
||||||
|
val content = description ?: ""
|
||||||
|
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||||
|
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||||
|
val sig = Utils.sign(id, privateKey)
|
||||||
|
return FileHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AESGCM(val key: String, val nonce: String)
|
||||||
@@ -4,7 +4,6 @@ import android.util.Log
|
|||||||
import com.vitorpamplona.amethyst.model.HexKey
|
import com.vitorpamplona.amethyst.model.HexKey
|
||||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||||
import com.vitorpamplona.amethyst.service.relays.Client
|
import com.vitorpamplona.amethyst.service.relays.Client
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
class LnZapEvent(
|
class LnZapEvent(
|
||||||
id: HexKey,
|
id: HexKey,
|
||||||
@@ -14,25 +13,37 @@ class LnZapEvent(
|
|||||||
content: String,
|
content: String,
|
||||||
sig: HexKey
|
sig: HexKey
|
||||||
) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||||
|
// This event is also kept in LocalCache (same object)
|
||||||
|
@Transient val zapRequest: LnZapRequestEvent?
|
||||||
|
|
||||||
override fun zappedPost() = tags
|
private fun containedPost(): LnZapRequestEvent? = try {
|
||||||
.filter { it.firstOrNull() == "e" }
|
description()?.ifBlank { null }?.let {
|
||||||
.mapNotNull { it.getOrNull(1) }
|
fromJson(it, Client.lenient)
|
||||||
|
} as? LnZapRequestEvent
|
||||||
override fun zappedPollOption(): Int? = containedPost()?.tags
|
} catch (e: Exception) {
|
||||||
?.filter { it.firstOrNull() == POLL_OPTION }
|
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
|
||||||
?.getOrNull(0)?.getOrNull(1)?.toInt()
|
null
|
||||||
|
|
||||||
override fun zappedAuthor() = tags
|
|
||||||
.filter { it.firstOrNull() == "p" }
|
|
||||||
.mapNotNull { it.getOrNull(1) }
|
|
||||||
|
|
||||||
override fun zappedRequestAuthor(): String? = containedPost()?.pubKey()
|
|
||||||
|
|
||||||
override fun amount(): BigDecimal? {
|
|
||||||
return amount
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
zapRequest = containedPost()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||||
|
|
||||||
|
override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||||
|
|
||||||
|
override fun zappedPollOption(): Int? = try {
|
||||||
|
zapRequest?.tags?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION }?.get(1)?.toInt()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("LnZapEvent", "ZappedPollOption failed to parse", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun zappedRequestAuthor(): String? = zapRequest?.pubKey()
|
||||||
|
|
||||||
|
override fun amount() = amount
|
||||||
|
|
||||||
// Keeps this as a field because it's a heavier function used everywhere.
|
// Keeps this as a field because it's a heavier function used everywhere.
|
||||||
val amount by lazy {
|
val amount by lazy {
|
||||||
try {
|
try {
|
||||||
@@ -42,29 +53,14 @@ class LnZapEvent(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun message(): String {
|
override fun message(): String {
|
||||||
return message
|
return content
|
||||||
}
|
|
||||||
val message = content
|
|
||||||
|
|
||||||
override fun containedPost(): Event? = try {
|
|
||||||
description()?.ifBlank { null }?.let {
|
|
||||||
fromJson(it, Client.lenient)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun lnInvoice(): String? = tags
|
private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
|
||||||
.filter { it.firstOrNull() == "bolt11" }
|
|
||||||
.mapNotNull { it.getOrNull(1) }
|
|
||||||
.firstOrNull()
|
|
||||||
|
|
||||||
private fun description(): String? = tags
|
private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
|
||||||
.filter { it.firstOrNull() == "description" }
|
|
||||||
.mapNotNull { it.getOrNull(1) }
|
|
||||||
.firstOrNull()
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val kind = 9735
|
const val kind = 9735
|
||||||
|
|||||||
@@ -16,7 +16,5 @@ interface LnZapEventInterface : EventInterface {
|
|||||||
|
|
||||||
fun amount(): BigDecimal?
|
fun amount(): BigDecimal?
|
||||||
|
|
||||||
fun containedPost(): Event?
|
|
||||||
|
|
||||||
fun message(): String
|
fun message(): String
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ class LnZapRequestEvent(
|
|||||||
content: String,
|
content: String,
|
||||||
sig: HexKey
|
sig: HexKey
|
||||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||||
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
|
||||||
fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||||
|
|
||||||
|
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val kind = 9734
|
const val kind = 9734
|
||||||
|
|||||||
@@ -20,24 +20,17 @@ class PollNoteEvent(
|
|||||||
content: String,
|
content: String,
|
||||||
sig: HexKey
|
sig: HexKey
|
||||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||||
fun pollOptions(): Map<Int, String> {
|
fun pollOptions() =
|
||||||
val map = mutableMapOf<Int, String>()
|
tags.filter { it.size > 2 && it[0] == POLL_OPTION }
|
||||||
tags.filter { it.first() == POLL_OPTION }
|
.associate { it[1].toInt() to it[2] }
|
||||||
.forEach { map[it[1].toInt()] = it[2] }
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getTagInt(property: String): Int? {
|
fun getTagInt(property: String): Int? {
|
||||||
val tagList = tags.filter {
|
val number = tags.firstOrNull() { it.size > 1 && it[0] == property }?.get(1)
|
||||||
it.firstOrNull() == property
|
|
||||||
}
|
|
||||||
val tag = tagList.getOrNull(0)
|
|
||||||
val s = tag?.getOrNull(1)
|
|
||||||
|
|
||||||
return if (s.isNullOrBlank() || s == "null") {
|
return if (number.isNullOrBlank() || number == "null") {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
s.toInt()
|
number.toInt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class PrivateDmEvent(
|
|||||||
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
|
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
|
||||||
* for initial messages.
|
* for initial messages.
|
||||||
*/
|
*/
|
||||||
fun recipientPubKey() = tags.firstOrNull { it.firstOrNull() == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
|
fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
|
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
|
||||||
@@ -29,7 +29,7 @@ class PrivateDmEvent(
|
|||||||
* Nip-18 messages should refer to other events by inline references in the content like
|
* Nip-18 messages should refer to other events by inline references in the content like
|
||||||
* `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506).
|
* `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506).
|
||||||
*/
|
*/
|
||||||
fun replyTo() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
|
||||||
|
|
||||||
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
|
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ class ReactionEvent(
|
|||||||
sig: HexKey
|
sig: HexKey
|
||||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||||
|
|
||||||
fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||||
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val kind = 7
|
const val kind = 7
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class ReportEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun reportedPost() = tags
|
fun reportedPost() = tags
|
||||||
.filter { it.firstOrNull() == "e" && it.getOrNull(1) != null }
|
.filter { it.size > 1 && it[0] == "e" }
|
||||||
.map {
|
.map {
|
||||||
ReportedKey(
|
ReportedKey(
|
||||||
it[1],
|
it[1],
|
||||||
@@ -39,7 +39,7 @@ class ReportEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun reportedAuthor() = tags
|
fun reportedAuthor() = tags
|
||||||
.filter { it.firstOrNull() == "p" && it.getOrNull(1) != null }
|
.filter { it.size > 1 && it[0] == "p" }
|
||||||
.map {
|
.map {
|
||||||
ReportedKey(
|
ReportedKey(
|
||||||
it[1],
|
it[1],
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import coil.decode.ImageDecoderDecoder
|
|||||||
import coil.decode.SvgDecoder
|
import coil.decode.SvgDecoder
|
||||||
import com.vitorpamplona.amethyst.LocalPreferences
|
import com.vitorpamplona.amethyst.LocalPreferences
|
||||||
import com.vitorpamplona.amethyst.ServiceManager
|
import com.vitorpamplona.amethyst.ServiceManager
|
||||||
|
import com.vitorpamplona.amethyst.VideoCache
|
||||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||||
import com.vitorpamplona.amethyst.service.relays.Client
|
import com.vitorpamplona.amethyst.service.relays.Client
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||||
@@ -54,6 +55,9 @@ class MainActivity : FragmentActivity() {
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initializes video cache.
|
||||||
|
VideoCache.init(this.applicationContext)
|
||||||
|
|
||||||
Coil.setImageLoader {
|
Coil.setImageLoader {
|
||||||
ImageLoader.Builder(this).components {
|
ImageLoader.Builder(this).components {
|
||||||
if (SDK_INT >= 28) {
|
if (SDK_INT >= 28) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ object ImageUploader {
|
|||||||
fun uploadImage(
|
fun uploadImage(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
contentResolver: ContentResolver,
|
contentResolver: ContentResolver,
|
||||||
onSuccess: (String) -> Unit,
|
onSuccess: (String, String?) -> Unit,
|
||||||
onError: (Throwable) -> Unit
|
onError: (Throwable) -> Unit
|
||||||
) {
|
) {
|
||||||
val contentType = contentResolver.getType(uri)
|
val contentType = contentResolver.getType(uri)
|
||||||
@@ -64,7 +64,7 @@ object ImageUploader {
|
|||||||
"There must be an uploaded image URL in the response"
|
"There must be an uploaded image URL in the response"
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuccess(url)
|
onSuccess(url, contentType)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
|||||||
@@ -9,21 +9,21 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19
|
|||||||
|
|
||||||
class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
|
class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
|
||||||
|
|
||||||
open fun addUserToMentions(user: User) {
|
fun addUserToMentions(user: User) {
|
||||||
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
|
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun addNoteToReplyTos(note: Note) {
|
fun addNoteToReplyTos(note: Note) {
|
||||||
note.author?.let { addUserToMentions(it) }
|
note.author?.let { addUserToMentions(it) }
|
||||||
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
|
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun tagIndex(user: User): Int {
|
fun tagIndex(user: User): Int {
|
||||||
// Postr Events assembles replies before mentions in the tag order
|
// Postr Events assembles replies before mentions in the tag order
|
||||||
return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
|
return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun tagIndex(note: Note): Int {
|
fun tagIndex(note: Note): Int {
|
||||||
// Postr Events assembles replies before mentions in the tag order
|
// Postr Events assembles replies before mentions in the tag order
|
||||||
return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
|
return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
@@ -15,15 +14,6 @@ import com.vitorpamplona.amethyst.R
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) {
|
fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) {
|
||||||
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
|
|
||||||
focusedBorderColor = MaterialTheme.colors.error,
|
|
||||||
unfocusedBorderColor = Color.Red
|
|
||||||
)
|
|
||||||
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
|
|
||||||
focusedBorderColor = MaterialTheme.colors.primary,
|
|
||||||
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
|
||||||
)
|
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
val deleteIcon: @Composable (() -> Unit) = {
|
val deleteIcon: @Composable (() -> Unit) = {
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
|||||||
Text(stringResource(R.string.poll_heading_required))
|
Text(stringResource(R.string.poll_heading_required))
|
||||||
// NewPollRecipientsField(pollViewModel, account)
|
// NewPollRecipientsField(pollViewModel, account)
|
||||||
NewPollPrimaryDescription(pollViewModel)
|
NewPollPrimaryDescription(pollViewModel)
|
||||||
pollViewModel.pollOptions.values.forEachIndexed { index, element ->
|
pollViewModel.pollOptions.values.forEachIndexed { index, _ ->
|
||||||
NewPollOption(pollViewModel, index)
|
NewPollOption(pollViewModel, index)
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
@@ -150,7 +150,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
|||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
userSuggestions,
|
userSuggestions,
|
||||||
key = { _, item -> item.pubkeyHex }
|
key = { _, item -> item.pubkeyHex }
|
||||||
) { index, item ->
|
) { _, item ->
|
||||||
UserLine(item, account) {
|
UserLine(item, account) {
|
||||||
pollViewModel.autocompleteWithUser(item)
|
pollViewModel.autocompleteWithUser(item)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (postViewModel.wantsPoll) {
|
if (postViewModel.wantsPoll) {
|
||||||
postViewModel.pollOptions.values.forEachIndexed { index, element ->
|
postViewModel.pollOptions.values.forEachIndexed { index, _ ->
|
||||||
NewPollOption(postViewModel, index)
|
NewPollOption(postViewModel, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
|||||||
val user = postViewModel.account?.userProfile()
|
val user = postViewModel.account?.userProfile()
|
||||||
val lud16 = user?.info?.lnAddress()
|
val lud16 = user?.info?.lnAddress()
|
||||||
|
|
||||||
if (lud16 != null && user != null && postViewModel.wantsInvoice) {
|
if (lud16 != null && postViewModel.wantsInvoice) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
|
||||||
InvoiceRequest(
|
InvoiceRequest(
|
||||||
lud16,
|
lud16,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.compose.ui.text.input.TextFieldValue
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.vitorpamplona.amethyst.model.*
|
import com.vitorpamplona.amethyst.model.*
|
||||||
|
import com.vitorpamplona.amethyst.service.FileHeader
|
||||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||||
@@ -72,7 +73,7 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
this.mentions = currentMentions.plus(replyUser)
|
this.mentions = currentMentions.plus(replyUser)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} ?: {
|
} ?: run {
|
||||||
replyTos = null
|
replyTos = null
|
||||||
mentions = null
|
mentions = null
|
||||||
}
|
}
|
||||||
@@ -110,13 +111,39 @@ open class NewPostViewModel : ViewModel() {
|
|||||||
ImageUploader.uploadImage(
|
ImageUploader.uploadImage(
|
||||||
uri = it,
|
uri = it,
|
||||||
contentResolver = context.contentResolver,
|
contentResolver = context.contentResolver,
|
||||||
onSuccess = { imageUrl ->
|
onSuccess = { imageUrl, mimeType ->
|
||||||
isUploadingImage = false
|
|
||||||
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
delay(2000)
|
// Images don't seem to be ready immediately after upload
|
||||||
urlPreview = findUrlInMessage()
|
|
||||||
|
if (mimeType?.startsWith("image/") == true) {
|
||||||
|
delay(2000)
|
||||||
|
} else {
|
||||||
|
delay(5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
FileHeader.prepare(
|
||||||
|
imageUrl,
|
||||||
|
mimeType,
|
||||||
|
onReady = {
|
||||||
|
val note = account?.sendHeader(it)
|
||||||
|
|
||||||
|
isUploadingImage = false
|
||||||
|
|
||||||
|
if (note == null) {
|
||||||
|
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
||||||
|
} else {
|
||||||
|
message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote())
|
||||||
|
}
|
||||||
|
|
||||||
|
urlPreview = findUrlInMessage()
|
||||||
|
},
|
||||||
|
onError = {
|
||||||
|
isUploadingImage = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
imageUploadingError.emit("Failed to upload the image / video")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError = {
|
onError = {
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
|||||||
ImageUploader.uploadImage(
|
ImageUploader.uploadImage(
|
||||||
uri = it,
|
uri = it,
|
||||||
contentResolver = context.contentResolver,
|
contentResolver = context.contentResolver,
|
||||||
onSuccess = { imageUrl ->
|
onSuccess = { imageUrl, mimeType ->
|
||||||
onUploading(false)
|
onUploading(false)
|
||||||
onUploaded(imageUrl)
|
onUploaded(imageUrl)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is designed to have a waiting time between two calls of invalidate
|
* This class is designed to have a waiting time between two calls of invalidate
|
||||||
@@ -44,3 +45,37 @@ class BundledUpdate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is designed to have a waiting time between two calls of invalidate
|
||||||
|
*/
|
||||||
|
class BundledInsert<T>(
|
||||||
|
val delay: Long,
|
||||||
|
val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||||
|
) {
|
||||||
|
private var onlyOneInBlock = AtomicBoolean()
|
||||||
|
private var atomicSet = AtomicReference<Set<T>>(setOf<T>())
|
||||||
|
|
||||||
|
fun invalidateList(newObject: T, onUpdate: (Set<T>) -> Unit) {
|
||||||
|
atomicSet.updateAndGet() {
|
||||||
|
it + newObject
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onlyOneInBlock.getAndSet(true)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val scope = CoroutineScope(Job() + dispatcher)
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
onUpdate(atomicSet.getAndSet(emptySet()))
|
||||||
|
delay(delay)
|
||||||
|
onUpdate(atomicSet.getAndSet(emptySet()))
|
||||||
|
} finally {
|
||||||
|
withContext(NonCancellable) {
|
||||||
|
onlyOneInBlock.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import androidx.compose.material.LocalTextStyle
|
|||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -32,6 +33,8 @@ import com.halilibo.richtext.markdown.MarkdownParseOptions
|
|||||||
import com.halilibo.richtext.ui.RichTextStyle
|
import com.halilibo.richtext.ui.RichTextStyle
|
||||||
import com.halilibo.richtext.ui.material.MaterialRichText
|
import com.halilibo.richtext.ui.material.MaterialRichText
|
||||||
import com.halilibo.richtext.ui.resolveDefaults
|
import com.halilibo.richtext.ui.resolveDefaults
|
||||||
|
import com.linkedin.urls.detection.UrlDetector
|
||||||
|
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
@@ -123,48 +126,43 @@ fun RichTextViewer(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
|
||||||
|
|
||||||
MaterialRichText(
|
MaterialRichText(
|
||||||
style = myMarkDownStyle
|
style = myMarkDownStyle
|
||||||
) {
|
) {
|
||||||
Markdown(
|
Markdown(
|
||||||
content = content,
|
content = markdownWithSpecialContent,
|
||||||
markdownParseOptions = MarkdownParseOptions.Default
|
markdownParseOptions = MarkdownParseOptions.Default
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val imagesForPager = mutableListOf<String>()
|
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
|
||||||
|
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
|
||||||
content.split('\n').forEach { paragraph ->
|
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
||||||
paragraph.split(' ').forEach { word: String ->
|
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||||
// sequence of images will render in a slideview
|
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||||
if (isValidURL(word)) {
|
ZoomableImage(fullUrl)
|
||||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
ZoomableVideo(fullUrl)
|
||||||
imagesForPager.add(word)
|
} else {
|
||||||
}
|
null
|
||||||
if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
|
||||||
imagesForPager.add(word)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}.associateBy { it.url }
|
||||||
|
val imageList = imagesForPager.values.toList()
|
||||||
|
|
||||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||||
content.split('\n').forEach { paragraph ->
|
content.split('\n').forEach { paragraph ->
|
||||||
FlowRow() {
|
FlowRow() {
|
||||||
val s = if (isArabic(paragraph)) paragraph.split(' ').reversed() else paragraph.split(' ')
|
val s = if (isArabic(paragraph)) paragraph.trim().split(' ').reversed() else paragraph.trim().split(' ')
|
||||||
s.forEach { word: String ->
|
s.forEach { word: String ->
|
||||||
if (canPreview) {
|
if (canPreview) {
|
||||||
// Explicit URL
|
// Explicit URL
|
||||||
if (isValidURL(word)) {
|
val img = imagesForPager[word]
|
||||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
if (img != null) {
|
||||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
ZoomableContentView(img, imageList)
|
||||||
ZoomableImageView(word, imagesForPager)
|
} else if (urlSet.contains(word)) {
|
||||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
UrlPreview(word, "$word ")
|
||||||
ZoomableImageView(word, imagesForPager)
|
|
||||||
} else {
|
|
||||||
UrlPreview(word, "$word ")
|
|
||||||
}
|
|
||||||
} else if (word.startsWith("lnbc", true)) {
|
} else if (word.startsWith("lnbc", true)) {
|
||||||
MayBeInvoicePreview(word)
|
MayBeInvoicePreview(word)
|
||||||
} else if (word.startsWith("lnurl", true)) {
|
} else if (word.startsWith("lnurl", true)) {
|
||||||
@@ -192,7 +190,7 @@ fun RichTextViewer(
|
|||||||
navController
|
navController
|
||||||
)
|
)
|
||||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||||
HashTag(word, accountViewModel, navController)
|
HashTag(word, navController)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = "$word ",
|
text = "$word ",
|
||||||
@@ -205,8 +203,15 @@ fun RichTextViewer(
|
|||||||
val url = matcher.group(1) // url
|
val url = matcher.group(1) // url
|
||||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||||
|
|
||||||
ClickableUrl(url, "https://$url")
|
if (url != null) {
|
||||||
Text("$additionalChars ")
|
ClickableUrl(url, "https://$url")
|
||||||
|
Text("$additionalChars ")
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "$word ",
|
||||||
|
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = "$word ",
|
text = "$word ",
|
||||||
@@ -214,7 +219,7 @@ fun RichTextViewer(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (isValidURL(word)) {
|
if (urlSet.contains(word)) {
|
||||||
ClickableUrl("$word ", word)
|
ClickableUrl("$word ", word)
|
||||||
} else if (word.startsWith("lnurl", true)) {
|
} else if (word.startsWith("lnurl", true)) {
|
||||||
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
|
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
|
||||||
@@ -249,7 +254,7 @@ fun RichTextViewer(
|
|||||||
navController
|
navController
|
||||||
)
|
)
|
||||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||||
HashTag(word, accountViewModel, navController)
|
HashTag(word, navController)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = "$word ",
|
text = "$word ",
|
||||||
@@ -262,8 +267,15 @@ fun RichTextViewer(
|
|||||||
val url = matcher.group(1) // url
|
val url = matcher.group(1) // url
|
||||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||||
|
|
||||||
ClickableUrl(url, "https://$url")
|
if (url != null) {
|
||||||
Text("$additionalChars ")
|
ClickableUrl(url, "https://$url")
|
||||||
|
Text("$additionalChars ")
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "$word ",
|
||||||
|
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = "$word ",
|
text = "$word ",
|
||||||
@@ -278,6 +290,53 @@ fun RichTextViewer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun getDisplayNameFromUserNip19(parsedNip19: Nip19.Return): String? {
|
||||||
|
if (parsedNip19.type == Nip19.Type.USER) {
|
||||||
|
val userHex = parsedNip19.hex
|
||||||
|
val userBase = LocalCache.getOrCreateUser(userHex)
|
||||||
|
|
||||||
|
val userState by userBase.live().metadata.observeAsState()
|
||||||
|
val displayName = userState?.user?.bestDisplayName()
|
||||||
|
if (displayName !== null) {
|
||||||
|
return displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun returnMarkdownWithSpecialContent(content: String): String {
|
||||||
|
var returnContent = ""
|
||||||
|
content.split('\n').forEach { paragraph ->
|
||||||
|
paragraph.split(' ').forEach { word: String ->
|
||||||
|
if (isValidURL(word)) {
|
||||||
|
returnContent += "[$word]($word) "
|
||||||
|
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||||
|
returnContent += "[$word](mailto:$word) "
|
||||||
|
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||||
|
returnContent += "[$word](tel:$word) "
|
||||||
|
} else if (isBechLink(word)) {
|
||||||
|
val parsedNip19 = Nip19.uriToRoute(word)
|
||||||
|
returnContent += if (parsedNip19 !== null) {
|
||||||
|
val displayName = getDisplayNameFromUserNip19(parsedNip19)
|
||||||
|
if (displayName != null) {
|
||||||
|
"[@$displayName](nostr://$word) "
|
||||||
|
} else {
|
||||||
|
"$word "
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"$word "
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
returnContent += "$word "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
returnContent += "\n"
|
||||||
|
}
|
||||||
|
return returnContent
|
||||||
|
}
|
||||||
|
|
||||||
private fun isArabic(text: String): Boolean {
|
private fun isArabic(text: String): Boolean {
|
||||||
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
|
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
|
||||||
}
|
}
|
||||||
@@ -326,9 +385,11 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
|
|||||||
isQuotedNote = true,
|
isQuotedNote = true,
|
||||||
navController = navController
|
navController = navController
|
||||||
)
|
)
|
||||||
Text(
|
if (!it.second.isNullOrEmpty()) {
|
||||||
"${it.second} "
|
Text(
|
||||||
)
|
"${it.second} "
|
||||||
|
)
|
||||||
|
}
|
||||||
} ?: nip19Route?.let {
|
} ?: nip19Route?.let {
|
||||||
ClickableRoute(it, navController)
|
ClickableRoute(it, navController)
|
||||||
} ?: Text(text = "$word ")
|
} ?: Text(text = "$word ")
|
||||||
@@ -340,7 +401,7 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) {
|
fun HashTag(word: String, navController: NavController) {
|
||||||
var tagSuffixPair by remember { mutableStateOf<Pair<String, String?>?>(null) }
|
var tagSuffixPair by remember { mutableStateOf<Pair<String, String?>?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(key1 = word) {
|
LaunchedEffect(key1 = word) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import coil.fetch.Fetcher
|
|||||||
import coil.fetch.SourceResult
|
import coil.fetch.SourceResult
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.request.Options
|
import coil.request.Options
|
||||||
|
import coil.size.Size
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ private fun svgString(msg: String): String {
|
|||||||
|
|
||||||
class HashImageFetcher(
|
class HashImageFetcher(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
|
private val size: Size,
|
||||||
private val data: Uri
|
private val data: Uri
|
||||||
) : Fetcher {
|
) : Fetcher {
|
||||||
|
|
||||||
@@ -79,16 +81,17 @@ class HashImageFetcher(
|
|||||||
|
|
||||||
object Factory : Fetcher.Factory<Uri> {
|
object Factory : Fetcher.Factory<Uri> {
|
||||||
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
|
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||||
return HashImageFetcher(options.context, data)
|
return HashImageFetcher(options.context, options.size, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
object Robohash {
|
object Robohash {
|
||||||
fun imageRequest(context: Context, message: String): ImageRequest {
|
fun imageRequest(context: Context, message: String, robotSize: Size): ImageRequest {
|
||||||
return ImageRequest
|
return ImageRequest
|
||||||
.Builder(context)
|
.Builder(context)
|
||||||
.data("robohash:$message")
|
.data("robohash:$message")
|
||||||
.fetcherFactory(HashImageFetcher.Factory)
|
.fetcherFactory(HashImageFetcher.Factory)
|
||||||
|
.size(robotSize)
|
||||||
.crossfade(100)
|
.crossfade(100)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,17 @@ import androidx.compose.ui.graphics.FilterQuality
|
|||||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.compose.AsyncImagePainter
|
import coil.compose.AsyncImagePainter
|
||||||
import coil.compose.rememberAsyncImagePainter
|
import coil.compose.rememberAsyncImagePainter
|
||||||
|
import coil.size.Size
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RobohashAsyncImage(
|
fun RobohashAsyncImage(
|
||||||
robot: String,
|
robot: String,
|
||||||
|
robotSize: Dp,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentDescription: String? = null,
|
contentDescription: String? = null,
|
||||||
transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
|
transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
|
||||||
@@ -26,24 +30,33 @@ fun RobohashAsyncImage(
|
|||||||
colorFilter: ColorFilter? = null,
|
colorFilter: ColorFilter? = null,
|
||||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
with(LocalDensity.current) {
|
||||||
model = Robohash.imageRequest(LocalContext.current, robot),
|
AsyncImage(
|
||||||
contentDescription = contentDescription,
|
model = Robohash.imageRequest(
|
||||||
modifier = modifier,
|
LocalContext.current,
|
||||||
transform = transform,
|
robot,
|
||||||
onState = onState,
|
Size(robotSize.roundToPx(), robotSize.roundToPx())
|
||||||
alignment = alignment,
|
),
|
||||||
contentScale = contentScale,
|
contentDescription = contentDescription,
|
||||||
alpha = alpha,
|
modifier = modifier,
|
||||||
colorFilter = colorFilter,
|
transform = transform,
|
||||||
filterQuality = filterQuality
|
onState = onState,
|
||||||
)
|
alignment = alignment,
|
||||||
|
contentScale = contentScale,
|
||||||
|
alpha = alpha,
|
||||||
|
colorFilter = colorFilter,
|
||||||
|
filterQuality = filterQuality
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var imageErrors = setOf<String>()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RobohashFallbackAsyncImage(
|
fun RobohashFallbackAsyncImage(
|
||||||
robot: String,
|
robot: String,
|
||||||
model: String?,
|
robotSize: Dp,
|
||||||
|
model: String,
|
||||||
contentDescription: String?,
|
contentDescription: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
alignment: Alignment = Alignment.Center,
|
alignment: Alignment = Alignment.Center,
|
||||||
@@ -52,22 +65,47 @@ fun RobohashFallbackAsyncImage(
|
|||||||
colorFilter: ColorFilter? = null,
|
colorFilter: ColorFilter? = null,
|
||||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
if (imageErrors.contains(model)) {
|
||||||
val painter = rememberAsyncImagePainter(model = Robohash.imageRequest(context, robot))
|
RobohashAsyncImage(
|
||||||
|
robot = robot,
|
||||||
|
robotSize = robotSize,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = modifier,
|
||||||
|
alignment = alignment,
|
||||||
|
contentScale = contentScale,
|
||||||
|
alpha = alpha,
|
||||||
|
colorFilter = colorFilter,
|
||||||
|
filterQuality = filterQuality
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val painter = with(LocalDensity.current) {
|
||||||
|
rememberAsyncImagePainter(
|
||||||
|
model = Robohash.imageRequest(
|
||||||
|
context,
|
||||||
|
robot,
|
||||||
|
Size(robotSize.roundToPx(), robotSize.roundToPx())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = model,
|
model = model,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
placeholder = painter,
|
placeholder = painter,
|
||||||
fallback = painter,
|
fallback = painter,
|
||||||
error = painter,
|
error = painter,
|
||||||
alignment = alignment,
|
alignment = alignment,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
alpha = alpha,
|
alpha = alpha,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
filterQuality = filterQuality
|
filterQuality = filterQuality,
|
||||||
)
|
onError = {
|
||||||
|
imageErrors = imageErrors + model
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -82,9 +120,11 @@ fun RobohashAsyncImageProxy(
|
|||||||
colorFilter: ColorFilter? = null,
|
colorFilter: ColorFilter? = null,
|
||||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||||
) {
|
) {
|
||||||
if (model.url == null) {
|
val proxy = model.proxyUrl()
|
||||||
|
if (proxy == null) {
|
||||||
RobohashAsyncImage(
|
RobohashAsyncImage(
|
||||||
robot = robot,
|
robot = robot,
|
||||||
|
robotSize = model.size,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
alignment = alignment,
|
alignment = alignment,
|
||||||
@@ -96,7 +136,8 @@ fun RobohashAsyncImageProxy(
|
|||||||
} else {
|
} else {
|
||||||
RobohashFallbackAsyncImage(
|
RobohashFallbackAsyncImage(
|
||||||
robot = robot,
|
robot = robot,
|
||||||
model = model.proxyUrl(),
|
robotSize = model.size,
|
||||||
|
model = proxy,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
alignment = alignment,
|
alignment = alignment,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.components
|
package com.vitorpamplona.amethyst.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -10,6 +11,8 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.layout.wrapContentWidth
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Surface
|
import androidx.compose.material.Surface
|
||||||
@@ -22,11 +25,8 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
|
||||||
import com.google.accompanist.pager.HorizontalPager
|
|
||||||
import com.google.accompanist.pager.PagerState
|
|
||||||
|
|
||||||
@OptIn(ExperimentalPagerApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SlidingCarousel(
|
fun SlidingCarousel(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -39,7 +39,7 @@ fun SlidingCarousel(
|
|||||||
Box(
|
Box(
|
||||||
modifier = modifier.fillMaxWidth()
|
modifier = modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
HorizontalPager(count = itemsCount, state = pagerState) { page ->
|
HorizontalPager(pageCount = itemsCount, state = pagerState) { page ->
|
||||||
itemContent(page)
|
itemContent(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,24 +41,24 @@ fun UrlPreviewCard(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
val url = URL(previewInfo.url)
|
val validatedUrl = URL(previewInfo.url)
|
||||||
|
|
||||||
// correctly treating relative images
|
// correctly treating relative images
|
||||||
val imageUrl = if (previewInfo.image.startsWith("/")) {
|
val imageUrl = if (previewInfo.image.startsWith("/")) {
|
||||||
URL(url, previewInfo.image).toString()
|
URL(validatedUrl, previewInfo.image).toString()
|
||||||
} else {
|
} else {
|
||||||
previewInfo.image
|
previewInfo.image
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
contentDescription = stringResource(R.string.preview_card_image_for, url),
|
contentDescription = stringResource(R.string.preview_card_image_for, validatedUrl),
|
||||||
contentScale = ContentScale.FillWidth,
|
contentScale = ContentScale.FillWidth,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = url.host,
|
text = validatedUrl.host,
|
||||||
style = MaterialTheme.typography.caption,
|
style = MaterialTheme.typography.caption,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import com.google.android.exoplayer2.C
|
import com.google.android.exoplayer2.C
|
||||||
import com.google.android.exoplayer2.ExoPlayer
|
import com.google.android.exoplayer2.ExoPlayer
|
||||||
import com.google.android.exoplayer2.MediaItem
|
import com.google.android.exoplayer2.MediaItem
|
||||||
@@ -21,44 +25,54 @@ import com.vitorpamplona.amethyst.VideoCache
|
|||||||
@Composable
|
@Composable
|
||||||
fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) {
|
fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
|
||||||
|
|
||||||
val exoPlayer = remember {
|
val exoPlayer = remember(videoUri) {
|
||||||
ExoPlayer.Builder(context).build().apply {
|
ExoPlayer.Builder(context).build().apply {
|
||||||
repeatMode = Player.REPEAT_MODE_ALL
|
repeatMode = Player.REPEAT_MODE_ALL
|
||||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
|
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
|
||||||
setMediaSource(
|
setMediaSource(
|
||||||
ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri))
|
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(MediaItem.fromUri(videoUri))
|
||||||
)
|
)
|
||||||
prepare()
|
prepare()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val playerView = remember {
|
DisposableEffect(
|
||||||
StyledPlayerView(context).apply {
|
AndroidView(
|
||||||
player = exoPlayer
|
modifier = Modifier.fillMaxWidth(),
|
||||||
layoutParams = FrameLayout.LayoutParams(
|
factory = {
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
StyledPlayerView(context).apply {
|
||||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
player = exoPlayer
|
||||||
)
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
onDialog?.let { innerOnDialog ->
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
setFullscreenButtonClickListener {
|
)
|
||||||
innerOnDialog(it)
|
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||||
|
onDialog?.let { innerOnDialog ->
|
||||||
|
setFullscreenButtonClickListener {
|
||||||
|
exoPlayer.pause()
|
||||||
|
innerOnDialog(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_PAUSE -> {
|
||||||
|
exoPlayer.pause()
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
val lifecycle = lifecycleOwner.value.lifecycle
|
||||||
|
lifecycle.addObserver(observer)
|
||||||
|
|
||||||
DisposableEffect(exoPlayer) {
|
|
||||||
onDispose {
|
onDispose {
|
||||||
exoPlayer.release()
|
exoPlayer.release()
|
||||||
|
lifecycle.removeObserver(observer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AndroidView(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
factory = {
|
|
||||||
playerView
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.InlineTextContent
|
||||||
|
import androidx.compose.foundation.text.appendInlineContent
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.IconButton
|
||||||
|
import androidx.compose.material.LocalTextStyle
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Surface
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Report
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.compositeOver
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.Placeholder
|
||||||
|
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import coil.annotation.ExperimentalCoilApi
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.compose.AsyncImagePainter
|
||||||
|
import coil.imageLoader
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.toHexKey
|
||||||
|
import com.vitorpamplona.amethyst.service.BlurHashRequester
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.Nip05
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import net.engawapg.lib.zoomable.rememberZoomState
|
||||||
|
import net.engawapg.lib.zoomable.zoomable
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
abstract class ZoomableContent(
|
||||||
|
val url: String,
|
||||||
|
val description: String? = null,
|
||||||
|
val hash: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
class ZoomableImage(
|
||||||
|
url: String,
|
||||||
|
description: String? = null,
|
||||||
|
hash: String? = null,
|
||||||
|
val bluehash: String? = null
|
||||||
|
) : ZoomableContent(url, description, hash)
|
||||||
|
|
||||||
|
class ZoomableVideo(
|
||||||
|
url: String,
|
||||||
|
description: String? = null,
|
||||||
|
hash: String? = null
|
||||||
|
) : ZoomableContent(url, description, hash)
|
||||||
|
|
||||||
|
fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
||||||
|
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||||
|
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
|
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
|
|
||||||
|
return if (isImage) {
|
||||||
|
ZoomableImage(fullUrl)
|
||||||
|
} else if (isVideo) {
|
||||||
|
ZoomableVideo(fullUrl)
|
||||||
|
} else {
|
||||||
|
ZoomableImage(fullUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent> = listOf(content)) {
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// store the dialog open or close state
|
||||||
|
var dialogOpen by remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// store the dialog open or close state
|
||||||
|
var imageState by remember {
|
||||||
|
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
var verifiedHash by remember {
|
||||||
|
mutableStateOf<Boolean?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
||||||
|
if (imageState is AsyncImagePainter.State.Success) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
verifiedHash = verifyHash(content, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val mainImageModifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape = RoundedCornerShape(15.dp))
|
||||||
|
.border(
|
||||||
|
1.dp,
|
||||||
|
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||||
|
RoundedCornerShape(15.dp)
|
||||||
|
)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = { dialogOpen = true },
|
||||||
|
onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (content is ZoomableImage) {
|
||||||
|
Box() {
|
||||||
|
AsyncImage(
|
||||||
|
model = content.url,
|
||||||
|
contentDescription = content.description,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier = mainImageModifier,
|
||||||
|
onLoading = {
|
||||||
|
imageState = it
|
||||||
|
},
|
||||||
|
onSuccess = {
|
||||||
|
imageState = it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (imageState !is AsyncImagePainter.State.Success) {
|
||||||
|
if (content.bluehash != null) {
|
||||||
|
DisplayBlueHash(content, mainImageModifier)
|
||||||
|
} else {
|
||||||
|
DisplayUrlWithLoadingSymbol(content)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VideoView(content.url) { dialogOpen = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogOpen) {
|
||||||
|
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
|
||||||
|
ClickableUrl(urlText = "$content ", url = content.url)
|
||||||
|
|
||||||
|
val myId = "inlineContent"
|
||||||
|
val emptytext = buildAnnotatedString {
|
||||||
|
withStyle(
|
||||||
|
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||||
|
) {
|
||||||
|
append("")
|
||||||
|
appendInlineContent(myId, "[icon]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val inlineContent = mapOf(
|
||||||
|
Pair(
|
||||||
|
myId,
|
||||||
|
InlineTextContent(
|
||||||
|
Placeholder(
|
||||||
|
width = 17.sp,
|
||||||
|
height = 17.sp,
|
||||||
|
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
LoadingAnimation()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Empty Text for Size of Icon
|
||||||
|
Text(
|
||||||
|
text = emptytext,
|
||||||
|
inlineContent = inlineContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DisplayBlueHash(
|
||||||
|
content: ZoomableImage,
|
||||||
|
modifier: Modifier
|
||||||
|
) {
|
||||||
|
if (content.bluehash == null) return
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
AsyncImage(
|
||||||
|
model = BlurHashRequester.imageRequest(
|
||||||
|
context,
|
||||||
|
content.bluehash
|
||||||
|
),
|
||||||
|
contentDescription = content.description,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableContent> = listOf(imageUrl), onDismiss: () -> Unit) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
|
) {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||||
|
Column() {
|
||||||
|
val pagerState: PagerState = remember { PagerState() }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(10.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
CloseButton(onCancel = onDismiss)
|
||||||
|
|
||||||
|
SaveToGallery(url = allImages[pagerState.currentPage].url)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allImages.size > 1) {
|
||||||
|
SlidingCarousel(
|
||||||
|
pagerState = pagerState,
|
||||||
|
itemsCount = allImages.size,
|
||||||
|
itemContent = { index ->
|
||||||
|
RenderImageOrVideo(allImages[index])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
RenderImageOrVideo(imageUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderImageOrVideo(content: ZoomableContent) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// store the dialog open or close state
|
||||||
|
var imageState by remember {
|
||||||
|
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
var verifiedHash by remember {
|
||||||
|
mutableStateOf<Boolean?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
||||||
|
if (imageState is AsyncImagePainter.State.Success) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
verifiedHash = verifyHash(content, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content is ZoomableImage) {
|
||||||
|
Box() {
|
||||||
|
AsyncImage(
|
||||||
|
model = content.url,
|
||||||
|
contentDescription = content.description,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.zoomable(rememberZoomState()),
|
||||||
|
onLoading = {
|
||||||
|
imageState = it
|
||||||
|
},
|
||||||
|
onSuccess = {
|
||||||
|
imageState = it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (imageState !is AsyncImagePainter.State.Success) {
|
||||||
|
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
|
||||||
|
} else {
|
||||||
|
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||||
|
VideoView(content.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoilApi::class)
|
||||||
|
private suspend fun verifyHash(content: ZoomableContent, context: Context): Boolean? {
|
||||||
|
if (content.hash == null) return null
|
||||||
|
|
||||||
|
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
|
||||||
|
val imageFile = snapshot.data.toFile()
|
||||||
|
val bytes = imageFile.readBytes()
|
||||||
|
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||||
|
|
||||||
|
val hash = sha256.digest(bytes).toHexKey()
|
||||||
|
|
||||||
|
Log.d("Image Hash Verification", "$hash == ${content.hash}")
|
||||||
|
|
||||||
|
return hash == content.hash
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HashVerificationSymbol(verifiedHash: Boolean?, modifier: Modifier) {
|
||||||
|
if (verifiedHash == null) return
|
||||||
|
|
||||||
|
val localContext = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.width(40.dp)
|
||||||
|
.height(40.dp)
|
||||||
|
.padding(10.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.fillMaxSize(0.6f)
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.background(MaterialTheme.colors.background)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (verifiedHash == true) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
Toast.makeText(
|
||||||
|
localContext,
|
||||||
|
localContext.getString(R.string.hash_verification_passed),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_verified),
|
||||||
|
"Hash Verified",
|
||||||
|
tint = Nip05.copy(0.52f).compositeOver(MaterialTheme.colors.background),
|
||||||
|
modifier = Modifier.size(30.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (verifiedHash == false) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
Toast.makeText(
|
||||||
|
localContext,
|
||||||
|
localContext.getString(R.string.hash_verification_failed),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
tint = Color.Red,
|
||||||
|
imageVector = Icons.Default.Report,
|
||||||
|
contentDescription = "Invalid Hash",
|
||||||
|
modifier = Modifier.size(30.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.components
|
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.InlineTextContent
|
|
||||||
import androidx.compose.foundation.text.appendInlineContent
|
|
||||||
import androidx.compose.material.LocalTextStyle
|
|
||||||
import androidx.compose.material.MaterialTheme
|
|
||||||
import androidx.compose.material.Surface
|
|
||||||
import androidx.compose.material.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.Placeholder
|
|
||||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.withStyle
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.compose.ui.window.DialogProperties
|
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import coil.compose.AsyncImagePainter
|
|
||||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
|
||||||
import com.google.accompanist.pager.PagerState
|
|
||||||
import com.vitorpamplona.amethyst.R
|
|
||||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
|
||||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
|
||||||
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
|
|
||||||
import net.engawapg.lib.zoomable.rememberZoomState
|
|
||||||
import net.engawapg.lib.zoomable.zoomable
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
fun ZoomableImageView(word: String, images: List<String> = listOf(word)) {
|
|
||||||
val clipboardManager = LocalClipboardManager.current
|
|
||||||
|
|
||||||
// store the dialog open or close state
|
|
||||||
var dialogOpen by remember {
|
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// store the dialog open or close state
|
|
||||||
var imageState by remember {
|
|
||||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
|
||||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
|
||||||
AsyncImage(
|
|
||||||
model = word,
|
|
||||||
contentDescription = word,
|
|
||||||
contentScale = ContentScale.FillWidth,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(shape = RoundedCornerShape(15.dp))
|
|
||||||
.border(
|
|
||||||
1.dp,
|
|
||||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
|
||||||
RoundedCornerShape(15.dp)
|
|
||||||
)
|
|
||||||
.combinedClickable(
|
|
||||||
onClick = { dialogOpen = true },
|
|
||||||
onLongClick = { clipboardManager.setText(AnnotatedString(word)) }
|
|
||||||
),
|
|
||||||
onLoading = {
|
|
||||||
imageState = it
|
|
||||||
},
|
|
||||||
onSuccess = {
|
|
||||||
imageState = it
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (imageState !is AsyncImagePainter.State.Success) {
|
|
||||||
ClickableUrl(urlText = "$word ", url = word)
|
|
||||||
|
|
||||||
val myId = "inlineContent"
|
|
||||||
val emptytext = buildAnnotatedString {
|
|
||||||
withStyle(
|
|
||||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
|
||||||
) {
|
|
||||||
append("")
|
|
||||||
appendInlineContent(myId, "[icon]")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val inlineContent = mapOf(
|
|
||||||
Pair(
|
|
||||||
myId,
|
|
||||||
InlineTextContent(
|
|
||||||
Placeholder(
|
|
||||||
width = 17.sp,
|
|
||||||
height = 17.sp,
|
|
||||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
LoadingAnimation()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Empty Text for Size of Icon
|
|
||||||
Text(
|
|
||||||
text = emptytext,
|
|
||||||
inlineContent = inlineContent
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
VideoView(word) { dialogOpen = true }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dialogOpen) {
|
|
||||||
ZoomableImageDialog(word, images, onDismiss = { dialogOpen = false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalPagerApi::class)
|
|
||||||
@Composable
|
|
||||||
fun ZoomableImageDialog(imageUrl: String, allImages: List<String> = listOf(imageUrl), onDismiss: () -> Unit) {
|
|
||||||
Dialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
|
||||||
) {
|
|
||||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
|
||||||
Column() {
|
|
||||||
var pagerState: PagerState = remember { PagerState() }
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(10.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
CloseButton(onCancel = onDismiss)
|
|
||||||
|
|
||||||
SaveToGallery(url = allImages[pagerState.currentPage])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allImages.size > 1) {
|
|
||||||
SlidingCarousel(
|
|
||||||
pagerState = pagerState,
|
|
||||||
itemsCount = allImages.size,
|
|
||||||
itemContent = { index ->
|
|
||||||
RenderImageOrVideo(allImages[index])
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
RenderImageOrVideo(imageUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RenderImageOrVideo(imageUrl: String) {
|
|
||||||
val removedParamsFromUrl = imageUrl.split("?")[0].lowercase()
|
|
||||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
|
||||||
AsyncImage(
|
|
||||||
model = imageUrl,
|
|
||||||
contentDescription = stringResource(id = R.string.profile_image),
|
|
||||||
contentScale = ContentScale.FillWidth,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.zoomable(rememberZoomState())
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
|
||||||
VideoView(imageUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.Channel
|
|||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
|
||||||
object ChannelFeedFilter : FeedFilter<Note>() {
|
object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
|
||||||
lateinit var account: Account
|
lateinit var account: Account
|
||||||
lateinit var channel: Channel
|
lateinit var channel: Channel
|
||||||
|
|
||||||
@@ -22,4 +22,14 @@ object ChannelFeedFilter : FeedFilter<Note>() {
|
|||||||
.sortedBy { it.createdAt() }
|
.sortedBy { it.createdAt() }
|
||||||
.reversed()
|
.reversed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||||
|
return collection
|
||||||
|
.filter { it.idHex in channel.notes.keys && account.isAcceptable(it) }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sort(collection: Set<Note>): List<Note> {
|
||||||
|
return collection.sortedBy { it.createdAt() }.reversed()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
|||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
|
|
||||||
object ChatroomFeedFilter : FeedFilter<Note>() {
|
object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
|
||||||
var account: Account? = null
|
var account: Account? = null
|
||||||
var withUser: User? = null
|
var withUser: User? = null
|
||||||
|
|
||||||
@@ -30,4 +30,23 @@ object ChatroomFeedFilter : FeedFilter<Note>() {
|
|||||||
.sortedBy { it.createdAt() }
|
.sortedBy { it.createdAt() }
|
||||||
.reversed()
|
.reversed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||||
|
val myAccount = account
|
||||||
|
val myUser = withUser
|
||||||
|
|
||||||
|
if (myAccount == null || myUser == null) return emptySet()
|
||||||
|
|
||||||
|
val messages = myAccount
|
||||||
|
.userProfile()
|
||||||
|
.privateChatrooms[myUser] ?: return emptySet()
|
||||||
|
|
||||||
|
return collection
|
||||||
|
.filter { it in messages.roomMessages && account?.isAcceptable(it) == true }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sort(collection: Set<Note>): List<Note> {
|
||||||
|
return collection.sortedBy { it.createdAt() }.reversed()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ object ChatroomListKnownFeedFilter : FeedFilter<Note>() {
|
|||||||
override fun feed(): List<Note> {
|
override fun feed(): List<Note> {
|
||||||
val me = account.userProfile()
|
val me = account.userProfile()
|
||||||
|
|
||||||
val privateChatrooms = account.userProfile().privateChatrooms
|
val privateChatrooms = me.privateChatrooms
|
||||||
val messagingWith = privateChatrooms.keys.filter {
|
val messagingWith = privateChatrooms.keys.filter {
|
||||||
me.hasSentMessagesTo(it) && account.isAcceptable(it)
|
me.hasSentMessagesTo(it) && account.isAcceptable(it)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import android.util.Log
|
|||||||
import kotlin.time.ExperimentalTime
|
import kotlin.time.ExperimentalTime
|
||||||
import kotlin.time.measureTimedValue
|
import kotlin.time.measureTimedValue
|
||||||
|
|
||||||
abstract class FeedFilter<T>() {
|
abstract class FeedFilter<T> {
|
||||||
@OptIn(ExperimentalTime::class)
|
@OptIn(ExperimentalTime::class)
|
||||||
fun loadTop(): List<T> {
|
fun loadTop(): List<T> {
|
||||||
val (feed, elapsed) = measureTimedValue {
|
val (feed, elapsed) = measureTimedValue {
|
||||||
@@ -17,3 +17,24 @@ abstract class FeedFilter<T>() {
|
|||||||
|
|
||||||
abstract fun feed(): List<T>
|
abstract fun feed(): List<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
|
||||||
|
abstract fun applyFilter(collection: Set<T>): Set<T>
|
||||||
|
abstract fun sort(collection: Set<T>): List<T>
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
|
||||||
|
val (feed, elapsed) = measureTimedValue {
|
||||||
|
val newItemsToBeAdded = applyFilter(newItems)
|
||||||
|
if (newItemsToBeAdded.isNotEmpty()) {
|
||||||
|
val newList = oldList.toSet() + newItemsToBeAdded
|
||||||
|
sort(newList).take(1000)
|
||||||
|
} else {
|
||||||
|
oldList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects")
|
||||||
|
return feed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,21 +5,32 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
|||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.service.model.*
|
import com.vitorpamplona.amethyst.service.model.*
|
||||||
|
|
||||||
object GlobalFeedFilter : FeedFilter<Note>() {
|
object GlobalFeedFilter : AdditiveFeedFilter<Note>() {
|
||||||
lateinit var account: Account
|
lateinit var account: Account
|
||||||
|
|
||||||
override fun feed(): List<Note> {
|
override fun feed(): List<Note> {
|
||||||
val followChannels = account.followingChannels()
|
val notes = innerApplyFilter(LocalCache.notes.values)
|
||||||
|
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
|
||||||
|
|
||||||
|
return sort(notes + longFormNotes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||||
|
return innerApplyFilter(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||||
|
val followChannels = account.followingChannels
|
||||||
val followUsers = account.followingKeySet()
|
val followUsers = account.followingKeySet()
|
||||||
val now = System.currentTimeMillis() / 1000
|
val now = System.currentTimeMillis() / 1000
|
||||||
|
|
||||||
val notes = LocalCache.notes.values
|
return collection
|
||||||
.asSequence()
|
.asSequence()
|
||||||
.filter {
|
.filter {
|
||||||
it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty()
|
it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty()
|
||||||
}
|
}
|
||||||
.filter {
|
.filter {
|
||||||
val channel = it.channel()
|
val channel = it.channelHex()
|
||||||
// does not show events already in the public chat list
|
// does not show events already in the public chat list
|
||||||
(channel == null || channel !in followChannels) &&
|
(channel == null || channel !in followChannels) &&
|
||||||
// does not show people the user already follows
|
// does not show people the user already follows
|
||||||
@@ -30,29 +41,10 @@ object GlobalFeedFilter : FeedFilter<Note>() {
|
|||||||
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
|
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
|
||||||
it.createdAt()!! <= now
|
it.createdAt()!! <= now
|
||||||
}
|
}
|
||||||
.toList()
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
val longFormNotes = LocalCache.addressables.values
|
override fun sort(collection: Set<Note>): List<Note> {
|
||||||
.asSequence()
|
return collection.sortedBy { it.createdAt() }.reversed()
|
||||||
.filter {
|
|
||||||
it.event is LongTextNoteEvent && it.replyTo.isNullOrEmpty()
|
|
||||||
}
|
|
||||||
.filter {
|
|
||||||
val channel = it.channel()
|
|
||||||
// does not show events already in the public chat list
|
|
||||||
(channel == null || channel !in followChannels) &&
|
|
||||||
// does not show people the user already follows
|
|
||||||
(it.author?.pubkeyHex !in followUsers)
|
|
||||||
}
|
|
||||||
.filter { account.isAcceptable(it) }
|
|
||||||
.filter {
|
|
||||||
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
|
|
||||||
it.createdAt()!! <= now
|
|
||||||
}
|
|
||||||
.toList()
|
|
||||||
|
|
||||||
return (notes + longFormNotes)
|
|
||||||
.sortedBy { it.createdAt() }
|
|
||||||
.reversed()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,27 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
|||||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||||
|
|
||||||
object HashtagFeedFilter : FeedFilter<Note>() {
|
object HashtagFeedFilter : AdditiveFeedFilter<Note>() {
|
||||||
lateinit var account: Account
|
lateinit var account: Account
|
||||||
var tag: String? = null
|
var tag: String? = null
|
||||||
|
|
||||||
override fun feed(): List<Note> {
|
fun loadHashtag(account: Account, tag: String?) {
|
||||||
val myTag = tag ?: return emptyList()
|
this.account = account
|
||||||
|
this.tag = tag
|
||||||
|
}
|
||||||
|
|
||||||
return LocalCache.notes.values
|
override fun feed(): List<Note> {
|
||||||
|
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||||
|
return innerApplyFilter(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||||
|
val myTag = tag ?: return emptySet()
|
||||||
|
|
||||||
|
return collection
|
||||||
.asSequence()
|
.asSequence()
|
||||||
.filter {
|
.filter {
|
||||||
(
|
(
|
||||||
@@ -27,13 +40,10 @@ object HashtagFeedFilter : FeedFilter<Note>() {
|
|||||||
it.event?.isTaggedHash(myTag) == true
|
it.event?.isTaggedHash(myTag) == true
|
||||||
}
|
}
|
||||||
.filter { account.isAcceptable(it) }
|
.filter { account.isAcceptable(it) }
|
||||||
.sortedBy { it.createdAt() }
|
.toSet()
|
||||||
.toList()
|
|
||||||
.reversed()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadHashtag(account: Account, tag: String?) {
|
override fun sort(collection: Set<Note>): List<Note> {
|
||||||
this.account = account
|
return collection.sortedBy { it.createdAt() }.reversed()
|
||||||
this.tag = tag
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,24 @@ import com.vitorpamplona.amethyst.model.Note
|
|||||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||||
|
|
||||||
object HomeConversationsFeedFilter : FeedFilter<Note>() {
|
object HomeConversationsFeedFilter : AdditiveFeedFilter<Note>() {
|
||||||
lateinit var account: Account
|
lateinit var account: Account
|
||||||
|
|
||||||
override fun feed(): List<Note> {
|
override fun feed(): List<Note> {
|
||||||
|
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||||
|
return innerApplyFilter(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||||
val user = account.userProfile()
|
val user = account.userProfile()
|
||||||
val followingKeySet = user.cachedFollowingKeySet()
|
val followingKeySet = user.cachedFollowingKeySet()
|
||||||
val followingTagSet = user.cachedFollowingTagSet()
|
val followingTagSet = user.cachedFollowingTagSet()
|
||||||
|
|
||||||
return LocalCache.notes.values
|
return collection
|
||||||
|
.asSequence()
|
||||||
.filter {
|
.filter {
|
||||||
(it.event is TextNoteEvent || it.event is PollNoteEvent) &&
|
(it.event is TextNoteEvent || it.event is PollNoteEvent) &&
|
||||||
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
||||||
@@ -22,7 +31,10 @@ object HomeConversationsFeedFilter : FeedFilter<Note>() {
|
|||||||
it.author?.let { !account.isHidden(it) } ?: true &&
|
it.author?.let { !account.isHidden(it) } ?: true &&
|
||||||
!it.isNewThread()
|
!it.isNewThread()
|
||||||
}
|
}
|
||||||
.sortedBy { it.createdAt() }
|
.toSet()
|
||||||
.reversed()
|
}
|
||||||
|
|
||||||
|
override fun sort(collection: Set<Note>): List<Note> {
|
||||||
|
return collection.sortedBy { it.createdAt() }.reversed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,34 +8,38 @@ import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
|||||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||||
|
|
||||||
object HomeNewThreadFeedFilter : FeedFilter<Note>() {
|
object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
|
||||||
lateinit var account: Account
|
lateinit var account: Account
|
||||||
|
|
||||||
override fun feed(): List<Note> {
|
override fun feed(): List<Note> {
|
||||||
|
val notes = innerApplyFilter(LocalCache.notes.values)
|
||||||
|
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
|
||||||
|
|
||||||
|
return sort(notes + longFormNotes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||||
|
return innerApplyFilter(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||||
val user = account.userProfile()
|
val user = account.userProfile()
|
||||||
val followingKeySet = user.cachedFollowingKeySet()
|
val followingKeySet = user.cachedFollowingKeySet()
|
||||||
val followingTagSet = user.cachedFollowingTagSet()
|
val followingTagSet = user.cachedFollowingTagSet()
|
||||||
|
|
||||||
val notes = LocalCache.notes.values
|
return collection
|
||||||
|
.asSequence()
|
||||||
.filter { it ->
|
.filter { it ->
|
||||||
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is PollNoteEvent) &&
|
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent) &&
|
||||||
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
||||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||||
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
|
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
|
||||||
it.isNewThread()
|
it.isNewThread()
|
||||||
}
|
}
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
val longFormNotes = LocalCache.addressables.values
|
override fun sort(collection: Set<Note>): List<Note> {
|
||||||
.filter { it ->
|
return collection.sortedBy { it.createdAt() }.reversed()
|
||||||
(it.event is LongTextNoteEvent) &&
|
|
||||||
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
|
||||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
|
||||||
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
|
|
||||||
it.isNewThread()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (notes + longFormNotes)
|
|
||||||
.sortedBy { it.createdAt() }
|
|
||||||
.reversed()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,22 @@ import com.vitorpamplona.amethyst.model.Note
|
|||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.amethyst.service.model.*
|
import com.vitorpamplona.amethyst.service.model.*
|
||||||
|
|
||||||
object NotificationFeedFilter : FeedFilter<Note>() {
|
object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
|
||||||
lateinit var account: Account
|
lateinit var account: Account
|
||||||
|
|
||||||
override fun feed(): List<Note> {
|
override fun feed(): List<Note> {
|
||||||
|
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||||
|
return innerApplyFilter(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||||
val loggedInUser = account.userProfile()
|
val loggedInUser = account.userProfile()
|
||||||
val loggedInUserHex = loggedInUser.pubkeyHex
|
val loggedInUserHex = loggedInUser.pubkeyHex
|
||||||
|
|
||||||
return LocalCache.notes.values.filter {
|
return collection.filter {
|
||||||
it.event !is ChannelCreateEvent &&
|
it.event !is ChannelCreateEvent &&
|
||||||
it.event !is ChannelMetadataEvent &&
|
it.event !is ChannelMetadataEvent &&
|
||||||
it.event !is LnZapRequestEvent &&
|
it.event !is LnZapRequestEvent &&
|
||||||
@@ -23,9 +31,11 @@ object NotificationFeedFilter : FeedFilter<Note>() {
|
|||||||
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
|
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
|
||||||
(it.author == null || !account.isHidden(it.author!!.pubkeyHex)) &&
|
(it.author == null || !account.isHidden(it.author!!.pubkeyHex)) &&
|
||||||
tagsAnEventByUser(it, loggedInUser)
|
tagsAnEventByUser(it, loggedInUser)
|
||||||
}
|
}.toSet()
|
||||||
.sortedBy { it.createdAt() }
|
}
|
||||||
.reversed()
|
|
||||||
|
override fun sort(collection: Set<Note>): List<Note> {
|
||||||
|
return collection.sortedBy { it.createdAt() }.reversed()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun tagsAnEventByUser(note: Note, author: User): Boolean {
|
fun tagsAnEventByUser(note: Note, author: User): Boolean {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
|
||||||
val bottomNavigationItems = listOf(
|
val bottomNavigationItems = listOf(
|
||||||
Route.Home,
|
Route.Home,
|
||||||
@@ -135,6 +136,7 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) {
|
private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) {
|
||||||
Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) {
|
Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) {
|
||||||
@@ -159,13 +161,13 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac
|
|||||||
|
|
||||||
LaunchedEffect(key1 = notif) {
|
LaunchedEffect(key1 = notif) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
hasNewItems = route.hasNewItems(account, notif.cache)
|
hasNewItems = route.hasNewItems(account, notif.cache, emptySet())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(key1 = db) {
|
LaunchedEffect(key1 = db) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
hasNewItems = route.hasNewItems(account, notif.cache)
|
hasNewItems = route.hasNewItems(account, notif.cache, db)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.navigation
|
package com.vitorpamplona.amethyst.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -11,8 +13,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
|
||||||
import com.google.accompanist.pager.rememberPagerState
|
|
||||||
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
||||||
@@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
|
||||||
|
|
||||||
@OptIn(ExperimentalPagerApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavigation(
|
fun AppNavigation(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import androidx.navigation.navArgument
|
|||||||
import com.vitorpamplona.amethyst.NotificationCache
|
import com.vitorpamplona.amethyst.NotificationCache
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
||||||
@@ -17,7 +19,7 @@ import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
|||||||
sealed class Route(
|
sealed class Route(
|
||||||
val route: String,
|
val route: String,
|
||||||
val icon: Int,
|
val icon: Int,
|
||||||
val hasNewItems: (Account, NotificationCache) -> Boolean = { _, _ -> false },
|
val hasNewItems: (Account, NotificationCache, Set<com.vitorpamplona.amethyst.model.Note>) -> Boolean = { _, _, _ -> false },
|
||||||
val arguments: List<NamedNavArgument> = emptyList()
|
val arguments: List<NamedNavArgument> = emptyList()
|
||||||
) {
|
) {
|
||||||
val base: String
|
val base: String
|
||||||
@@ -30,7 +32,7 @@ sealed class Route(
|
|||||||
navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false },
|
navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false },
|
||||||
navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null }
|
navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null }
|
||||||
),
|
),
|
||||||
hasNewItems = { accountViewModel, cache -> homeHasNewItems(accountViewModel, cache) }
|
hasNewItems = { accountViewModel, cache, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
|
||||||
)
|
)
|
||||||
|
|
||||||
object Search : Route(
|
object Search : Route(
|
||||||
@@ -43,13 +45,13 @@ sealed class Route(
|
|||||||
route = "Notification?scrollToTop={scrollToTop}",
|
route = "Notification?scrollToTop={scrollToTop}",
|
||||||
icon = R.drawable.ic_notifications,
|
icon = R.drawable.ic_notifications,
|
||||||
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }),
|
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }),
|
||||||
hasNewItems = { accountViewModel, cache -> notificationHasNewItems(accountViewModel, cache) }
|
hasNewItems = { accountViewModel, cache, newNotes -> NotificationLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
|
||||||
)
|
)
|
||||||
|
|
||||||
object Message : Route(
|
object Message : Route(
|
||||||
route = "Message",
|
route = "Message",
|
||||||
icon = R.drawable.ic_dm,
|
icon = R.drawable.ic_dm,
|
||||||
hasNewItems = { accountViewModel, cache -> messagesHasNewItems(accountViewModel, cache) }
|
hasNewItems = { accountViewModel, cache, newNotes -> MessagesLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
|
||||||
)
|
)
|
||||||
|
|
||||||
object BlockedUsers : Route(
|
object BlockedUsers : Route(
|
||||||
@@ -108,36 +110,72 @@ fun currentRoute(navController: NavHostController): String? {
|
|||||||
return navBackStackEntry?.destination?.route
|
return navBackStackEntry?.destination?.route
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun homeHasNewItems(account: Account, cache: NotificationCache): Boolean {
|
open class LatestItem {
|
||||||
val lastTime = cache.load("HomeFollows")
|
var newestItemPerAccount: Map<String, Note?> = mapOf()
|
||||||
|
|
||||||
HomeNewThreadFeedFilter.account = account
|
fun updateNewestItem(newNotes: Set<Note>, account: Account, filter: AdditiveFeedFilter<Note>): Note? {
|
||||||
|
val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex]
|
||||||
|
|
||||||
return (
|
if (newestItem == null) {
|
||||||
HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt()
|
newestItemPerAccount = newestItemPerAccount + Pair(
|
||||||
?: 0
|
account.userProfile().pubkeyHex,
|
||||||
) > lastTime
|
filter.feed().firstOrNull { it.createdAt() != null }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
newestItemPerAccount = newestItemPerAccount + Pair(
|
||||||
|
account.userProfile().pubkeyHex,
|
||||||
|
filter.sort(filter.applyFilter(newNotes) + newestItem).first()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newestItemPerAccount[account.userProfile().pubkeyHex]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notificationHasNewItems(account: Account, cache: NotificationCache): Boolean {
|
object HomeLatestItem : LatestItem() {
|
||||||
val lastTime = cache.load("Notification")
|
fun hasNewItems(
|
||||||
|
account: Account,
|
||||||
|
cache: NotificationCache,
|
||||||
|
newNotes: Set<Note>
|
||||||
|
): Boolean {
|
||||||
|
val lastTime = cache.load("HomeFollows")
|
||||||
|
HomeNewThreadFeedFilter.account = account
|
||||||
|
|
||||||
NotificationFeedFilter.account = account
|
val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter)
|
||||||
|
|
||||||
return (
|
return (newestItem?.createdAt() ?: 0) > lastTime
|
||||||
NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt()
|
}
|
||||||
?: 0
|
|
||||||
) > lastTime
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun messagesHasNewItems(account: Account, cache: NotificationCache): Boolean {
|
object NotificationLatestItem : LatestItem() {
|
||||||
ChatroomListKnownFeedFilter.account = account
|
fun hasNewItems(
|
||||||
|
account: Account,
|
||||||
|
cache: NotificationCache,
|
||||||
|
newNotes: Set<Note>
|
||||||
|
): Boolean {
|
||||||
|
val lastTime = cache.load("Notification")
|
||||||
|
NotificationFeedFilter.account = account
|
||||||
|
|
||||||
val note = ChatroomListKnownFeedFilter.feed().firstOrNull {
|
val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter)
|
||||||
it.createdAt() != null && it.channel() == null && it.author != account.userProfile()
|
|
||||||
} ?: return false
|
|
||||||
|
|
||||||
val lastTime = cache.load("Room/${note.author?.pubkeyHex}")
|
return (newestItem?.createdAt() ?: 0) > lastTime
|
||||||
|
}
|
||||||
return (note.createdAt() ?: 0) > lastTime
|
}
|
||||||
|
|
||||||
|
object MessagesLatestItem {
|
||||||
|
fun hasNewItems(
|
||||||
|
account: Account,
|
||||||
|
cache: NotificationCache,
|
||||||
|
newNotes: Set<Note>
|
||||||
|
): Boolean {
|
||||||
|
ChatroomListKnownFeedFilter.account = account
|
||||||
|
|
||||||
|
val note = ChatroomListKnownFeedFilter.loadTop().firstOrNull {
|
||||||
|
it.createdAt() != null && it.channel() == null && it.author != account.userProfile()
|
||||||
|
} ?: return false
|
||||||
|
|
||||||
|
val lastTime = cache.load("Room/${note.author?.pubkeyHex}")
|
||||||
|
|
||||||
|
return (note.createdAt() ?: 0) > lastTime
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,30 +246,32 @@ fun ChatroomMessageCompose(
|
|||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
val event = note.event
|
val event = note.event
|
||||||
if (event is ChannelCreateEvent) {
|
if (event is ChannelCreateEvent) {
|
||||||
|
val channelInfo = event.channelInfo()
|
||||||
Text(
|
Text(
|
||||||
text = note.author?.toBestDisplayName()
|
text = note.author?.toBestDisplayName()
|
||||||
.toString() + " ${stringResource(R.string.created)} " + (
|
.toString() + " ${stringResource(R.string.created)} " + (
|
||||||
event.channelInfo().name
|
channelInfo.name
|
||||||
?: ""
|
?: ""
|
||||||
) + " ${stringResource(R.string.with_description_of)} '" + (
|
) + " ${stringResource(R.string.with_description_of)} '" + (
|
||||||
event.channelInfo().about
|
channelInfo.about
|
||||||
?: ""
|
?: ""
|
||||||
) + "', ${stringResource(R.string.and_picture)} '" + (
|
) + "', ${stringResource(R.string.and_picture)} '" + (
|
||||||
event.channelInfo().picture
|
channelInfo.picture
|
||||||
?: ""
|
?: ""
|
||||||
) + "'"
|
) + "'"
|
||||||
)
|
)
|
||||||
} else if (event is ChannelMetadataEvent) {
|
} else if (event is ChannelMetadataEvent) {
|
||||||
|
val channelInfo = event.channelInfo()
|
||||||
Text(
|
Text(
|
||||||
text = note.author?.toBestDisplayName()
|
text = note.author?.toBestDisplayName()
|
||||||
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (
|
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (
|
||||||
event.channelInfo().name
|
channelInfo.name
|
||||||
?: ""
|
?: ""
|
||||||
) + "$', {stringResource(R.string.description_to)} '" + (
|
) + "$', {stringResource(R.string.description_to)} '" + (
|
||||||
event.channelInfo().about
|
channelInfo.about
|
||||||
?: ""
|
?: ""
|
||||||
) + "', ${stringResource(R.string.and_picture_to)} '" + (
|
) + "', ${stringResource(R.string.and_picture_to)} '" + (
|
||||||
event.channelInfo().picture
|
channelInfo.picture
|
||||||
?: ""
|
?: ""
|
||||||
) + "'"
|
) + "'"
|
||||||
)
|
)
|
||||||
@@ -371,6 +373,7 @@ private fun RelayBadges(baseNote: Note) {
|
|||||||
) {
|
) {
|
||||||
RobohashFallbackAsyncImage(
|
RobohashFallbackAsyncImage(
|
||||||
robot = "https://$url/favicon.ico",
|
robot = "https://$url/favicon.ico",
|
||||||
|
robotSize = 15.dp,
|
||||||
model = "https://$url/favicon.ico",
|
model = "https://$url/favicon.ico",
|
||||||
contentDescription = stringResource(id = R.string.relay_icon),
|
contentDescription = stringResource(id = R.string.relay_icon),
|
||||||
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
|
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import com.vitorpamplona.amethyst.NotificationCache
|
import com.vitorpamplona.amethyst.NotificationCache
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||||
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
|
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -46,7 +48,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
|
|||||||
} else {
|
} else {
|
||||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||||
|
|
||||||
LaunchedEffect(key1 = messageSetCard) {
|
LaunchedEffect(key1 = messageSetCard.createdAt()) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
isNew =
|
isNew =
|
||||||
messageSetCard.createdAt() > NotificationCache.load(routeForLastRead)
|
messageSetCard.createdAt() > NotificationCache.load(routeForLastRead)
|
||||||
@@ -64,14 +66,29 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (noteEvent !is ChannelMessageEvent) {
|
if (noteEvent is ChannelMessageEvent) {
|
||||||
navController.navigate("Note/${note.idHex}") {
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
note.channel()?.let {
|
note.channel()?.let {
|
||||||
navController.navigate("Channel/${it.idHex}")
|
navController.navigate("Channel/${it.idHex}")
|
||||||
}
|
}
|
||||||
|
} else if (noteEvent is PrivateDmEvent) {
|
||||||
|
val replyAuthorBase =
|
||||||
|
(note.event as? PrivateDmEvent)
|
||||||
|
?.recipientPubKey()
|
||||||
|
?.let { LocalCache.getOrCreateUser(it) }
|
||||||
|
|
||||||
|
var userToComposeOn = note.author!!
|
||||||
|
|
||||||
|
if (replyAuthorBase != null) {
|
||||||
|
if (note.author == accountViewModel.userProfile()) {
|
||||||
|
userToComposeOn = replyAuthorBase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
|
||||||
|
} else {
|
||||||
|
navController.navigate("Note/${note.idHex}") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongClick = { popupExpanded = true }
|
onLongClick = { popupExpanded = true }
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
|||||||
} else {
|
} else {
|
||||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||||
|
|
||||||
LaunchedEffect(key1 = multiSetCard) {
|
LaunchedEffect(key1 = multiSetCard.createdAt()) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
|
isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ fun FastNoteAuthorPicture(
|
|||||||
val userState by author.live().metadata.observeAsState()
|
val userState by author.live().metadata.observeAsState()
|
||||||
val user = userState?.user ?: return
|
val user = userState?.user ?: return
|
||||||
|
|
||||||
val showFollowingMark = userAccount.isFollowingCached(user) || user == userAccount
|
val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount
|
||||||
|
|
||||||
UserPicture(
|
UserPicture(
|
||||||
userHex = user.pubkeyHex,
|
userHex = user.pubkeyHex,
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
|
|||||||
|
|
||||||
user.nip05()?.let { nip05 ->
|
user.nip05()?.let { nip05 ->
|
||||||
if (nip05.split("@").size == 2) {
|
if (nip05.split("@").size == 2) {
|
||||||
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
|
|
||||||
Column(modifier = columnModifier) {
|
Column(modifier = columnModifier) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (nip05.split("@")[0] != "_") {
|
if (nip05.split("@")[0] != "_") {
|
||||||
@@ -108,6 +107,7 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
|
||||||
if (nip05Verified == null) {
|
if (nip05Verified == null) {
|
||||||
Icon(
|
Icon(
|
||||||
tint = Color.Yellow,
|
tint = Color.Yellow,
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import android.graphics.Bitmap
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.CutCornerShape
|
import androidx.compose.foundation.shape.CutCornerShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -61,7 +59,6 @@ import com.vitorpamplona.amethyst.ui.theme.Following
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import kotlin.math.ceil
|
|
||||||
import kotlin.time.ExperimentalTime
|
import kotlin.time.ExperimentalTime
|
||||||
import kotlin.time.measureTimedValue
|
import kotlin.time.measureTimedValue
|
||||||
|
|
||||||
@@ -96,7 +93,7 @@ fun NoteCompose(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d("Time", "Note Compose in $elapsed for ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}")
|
Log.d("Time", "Note Compose in $elapsed for ${baseNote.idHex} ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@@ -170,6 +167,8 @@ fun NoteComposeInner(
|
|||||||
ChannelHeader(baseChannel = baseChannel, account = account, navController = navController)
|
ChannelHeader(baseChannel = baseChannel, account = account, navController = navController)
|
||||||
} else if (noteEvent is BadgeDefinitionEvent) {
|
} else if (noteEvent is BadgeDefinitionEvent) {
|
||||||
BadgeDisplay(baseNote = note)
|
BadgeDisplay(baseNote = note)
|
||||||
|
} else if (noteEvent is FileHeaderEvent) {
|
||||||
|
FileHeaderDisplay(note)
|
||||||
} else {
|
} else {
|
||||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||||
|
|
||||||
@@ -299,7 +298,7 @@ fun NoteComposeInner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (noteEvent is RepostEvent) {
|
if (noteEvent is RepostEvent) {
|
||||||
note.replyTo?.lastOrNull()?.let {
|
baseNote.replyTo?.lastOrNull()?.let {
|
||||||
RelayBadges(it)
|
RelayBadges(it)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -496,13 +495,13 @@ fun NoteComposeInner(
|
|||||||
noteEvent.recipientPubKey() != loggedIn.pubkeyHex &&
|
noteEvent.recipientPubKey() != loggedIn.pubkeyHex &&
|
||||||
note.author !== loggedIn
|
note.author !== loggedIn
|
||||||
) {
|
) {
|
||||||
val recepient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) }
|
val recipient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) }
|
||||||
|
|
||||||
TranslatableRichTextViewer(
|
TranslatableRichTextViewer(
|
||||||
stringResource(
|
stringResource(
|
||||||
id = R.string.private_conversation_notification,
|
id = R.string.private_conversation_notification,
|
||||||
"@${note.author?.pubkeyNpub()}",
|
"@${note.author?.pubkeyNpub()}",
|
||||||
"@${recepient?.pubkeyNpub()}"
|
"@${recipient?.pubkeyNpub()}"
|
||||||
),
|
),
|
||||||
canPreview = !makeItShort,
|
canPreview = !makeItShort,
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
@@ -582,7 +581,9 @@ fun DisplayFollowingHashtagsInPost(
|
|||||||
var firstTag by remember { mutableStateOf<String?>(null) }
|
var firstTag by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(key1 = noteEvent) {
|
LaunchedEffect(key1 = noteEvent) {
|
||||||
firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet())
|
withContext(Dispatchers.IO) {
|
||||||
|
firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column() {
|
Column() {
|
||||||
@@ -777,6 +778,30 @@ fun BadgeDisplay(baseNote: Note) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FileHeaderDisplay(note: Note) {
|
||||||
|
val event = (note.event as? FileHeaderEvent) ?: return
|
||||||
|
|
||||||
|
val fullUrl = event.url() ?: return
|
||||||
|
val blurHash = event.blurhash()
|
||||||
|
val hash = event.hash()
|
||||||
|
val description = event.content
|
||||||
|
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||||
|
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
|
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||||
|
|
||||||
|
if (isImage || isVideo) {
|
||||||
|
val content = if (isImage) {
|
||||||
|
ZoomableImage(fullUrl, description, hash, blurHash)
|
||||||
|
} else {
|
||||||
|
ZoomableVideo(fullUrl, description, hash)
|
||||||
|
}
|
||||||
|
ZoomableContentView(content = content, listOf(content))
|
||||||
|
} else {
|
||||||
|
UrlPreview(fullUrl, "$fullUrl ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
|
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
|
||||||
Row(
|
Row(
|
||||||
@@ -869,67 +894,102 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: U
|
|||||||
@Composable
|
@Composable
|
||||||
private fun RelayBadges(baseNote: Note) {
|
private fun RelayBadges(baseNote: Note) {
|
||||||
val noteRelaysState by baseNote.live().relays.observeAsState()
|
val noteRelaysState by baseNote.live().relays.observeAsState()
|
||||||
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
|
val noteRelays = noteRelaysState?.note ?: return
|
||||||
|
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
var showShowMore by remember { mutableStateOf(false) }
|
||||||
|
var lazyRelayList by remember { mutableStateOf(emptyList<String>()) }
|
||||||
|
|
||||||
val relaysToDisplay = (if (expanded) noteRelays else noteRelays.take(3)).toList()
|
LaunchedEffect(key1 = noteRelaysState, key2 = expanded) {
|
||||||
val height = (ceil(relaysToDisplay.size / 3.0f) * 17).dp
|
withContext(Dispatchers.IO) {
|
||||||
|
val relayList = noteRelays.relays.map {
|
||||||
|
it.removePrefix("wss://").removePrefix("ws://")
|
||||||
|
}
|
||||||
|
|
||||||
val uri = LocalUriHandler.current
|
val relaysToDisplay = if (expanded) relayList else relayList.take(3)
|
||||||
|
val shouldListChange = lazyRelayList.size < 3 || lazyRelayList.size != relayList.size
|
||||||
|
|
||||||
Spacer(Modifier.height(10.dp))
|
if (shouldListChange) {
|
||||||
|
lazyRelayList = relaysToDisplay
|
||||||
|
}
|
||||||
|
|
||||||
LazyVerticalGrid(
|
val nextShowMore = relayList.size > 3 && !expanded
|
||||||
columns = GridCells.Fixed(3),
|
if (nextShowMore != showShowMore) {
|
||||||
contentPadding = PaddingValues(start = 4.dp, end = 4.dp),
|
// only triggers recomposition when actually different
|
||||||
modifier = Modifier.height(height),
|
showShowMore = nextShowMore
|
||||||
userScrollEnabled = false
|
|
||||||
) {
|
|
||||||
items(relaysToDisplay.size) {
|
|
||||||
val url = relaysToDisplay[it].removePrefix("wss://").removePrefix("ws://")
|
|
||||||
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.padding(1.dp)
|
|
||||||
.size(15.dp)
|
|
||||||
) {
|
|
||||||
RobohashFallbackAsyncImage(
|
|
||||||
robot = "https://$url/favicon.ico",
|
|
||||||
model = "https://$url/favicon.ico",
|
|
||||||
contentDescription = stringResource(R.string.relay_icon),
|
|
||||||
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
|
|
||||||
modifier = Modifier
|
|
||||||
.width(13.dp)
|
|
||||||
.height(13.dp)
|
|
||||||
.clip(shape = CircleShape)
|
|
||||||
// .border(1.dp, Color.Red)
|
|
||||||
.background(MaterialTheme.colors.background)
|
|
||||||
.clickable(onClick = { uri.openUri("https://$url") })
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (noteRelays.size > 3 && !expanded) {
|
Spacer(Modifier.height(10.dp))
|
||||||
Row(
|
|
||||||
Modifier
|
VerticalRelayPanelWithFlow(lazyRelayList)
|
||||||
.fillMaxWidth()
|
|
||||||
.height(25.dp),
|
if (showShowMore) {
|
||||||
horizontalArrangement = Arrangement.Center,
|
ShowMoreRelaysButton {
|
||||||
verticalAlignment = Alignment.Top
|
expanded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Stable
|
||||||
|
private fun VerticalRelayPanelWithFlow(
|
||||||
|
relays: List<String>
|
||||||
|
) {
|
||||||
|
// FlowRow Seems to be a lot faster than LazyVerticalGrid
|
||||||
|
FlowRow() {
|
||||||
|
relays.forEach { url ->
|
||||||
|
RelayIconCompose(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Stable
|
||||||
|
private fun RelayIconCompose(url: String) {
|
||||||
|
val uri = LocalUriHandler.current
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(1.dp)
|
||||||
|
.size(15.dp)
|
||||||
|
) {
|
||||||
|
RobohashFallbackAsyncImage(
|
||||||
|
robot = "https://$url/favicon.ico",
|
||||||
|
robotSize = 15.dp,
|
||||||
|
model = "https://$url/favicon.ico",
|
||||||
|
contentDescription = stringResource(R.string.relay_icon),
|
||||||
|
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
|
||||||
|
modifier = Modifier
|
||||||
|
.width(13.dp)
|
||||||
|
.height(13.dp)
|
||||||
|
.clip(shape = CircleShape)
|
||||||
|
.background(MaterialTheme.colors.background)
|
||||||
|
.clickable(onClick = { uri.openUri("https://$url") })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ShowMoreRelaysButton(onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(25.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||||
|
onClick = onClick
|
||||||
) {
|
) {
|
||||||
IconButton(
|
Icon(
|
||||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
imageVector = Icons.Default.ExpandMore,
|
||||||
onClick = { expanded = true }
|
null,
|
||||||
) {
|
modifier = Modifier.size(15.dp),
|
||||||
Icon(
|
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
imageVector = Icons.Default.ExpandMore,
|
)
|
||||||
null,
|
|
||||||
modifier = Modifier.size(15.dp),
|
|
||||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -968,6 +1028,7 @@ fun NoteAuthorPicture(
|
|||||||
if (author == null) {
|
if (author == null) {
|
||||||
RobohashAsyncImage(
|
RobohashAsyncImage(
|
||||||
robot = "authornotfound",
|
robot = "authornotfound",
|
||||||
|
robotSize = size,
|
||||||
contentDescription = stringResource(R.string.unknown_author),
|
contentDescription = stringResource(R.string.unknown_author),
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(size)
|
.width(size)
|
||||||
@@ -1108,12 +1169,14 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(key1 = note) {
|
LaunchedEffect(key1 = note) {
|
||||||
state = DropDownParams(
|
withContext(Dispatchers.IO) {
|
||||||
accountViewModel.isFollowing(note.author),
|
state = DropDownParams(
|
||||||
accountViewModel.isInPrivateBookmarks(note),
|
accountViewModel.isFollowing(note.author),
|
||||||
accountViewModel.isInPublicBookmarks(note),
|
accountViewModel.isInPrivateBookmarks(note),
|
||||||
accountViewModel.isLoggedUser(note.author)
|
accountViewModel.isInPublicBookmarks(note),
|
||||||
)
|
accountViewModel.isLoggedUser(note.author)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
|
|||||||
@@ -48,9 +48,6 @@ fun PollNote(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
navController: NavController
|
navController: NavController
|
||||||
) {
|
) {
|
||||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
|
||||||
val account = accountState?.account ?: return
|
|
||||||
|
|
||||||
val zapsState by baseNote.live().zaps.observeAsState()
|
val zapsState by baseNote.live().zaps.observeAsState()
|
||||||
val zappedNote = zapsState?.note ?: return
|
val zappedNote = zapsState?.note ?: return
|
||||||
|
|
||||||
@@ -72,7 +69,7 @@ fun PollNote(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(vertical = 3.dp)
|
modifier = Modifier.padding(vertical = 3.dp)
|
||||||
) {
|
) {
|
||||||
if (zappedNote.author == account.userProfile() || zappedNote.isZappedBy(account.userProfile())) {
|
if (accountViewModel.isLoggedUser(zappedNote.author) || zappedNote.isZappedBy(accountViewModel.userProfile())) {
|
||||||
ZapVote(
|
ZapVote(
|
||||||
baseNote,
|
baseNote,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
@@ -90,7 +87,7 @@ fun PollNote(
|
|||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
modifier = Modifier.matchParentSize(),
|
modifier = Modifier.matchParentSize(),
|
||||||
color = color,
|
color = color,
|
||||||
progress = optionTally
|
progress = optionTally.toFloat()
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@@ -101,7 +98,7 @@ fun PollNote(
|
|||||||
modifier = Modifier.padding(horizontal = 10.dp).width(40.dp)
|
modifier = Modifier.padding(horizontal = 10.dp).width(40.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "${(optionTally * 100).roundToInt()}%",
|
text = "${(optionTally.toFloat() * 100).roundToInt()}%",
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -210,7 +207,7 @@ fun ZapVote(
|
|||||||
)
|
)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
} else if (zappedNote?.author == account.userProfile()) {
|
} else if (accountViewModel.isLoggedUser(zappedNote?.author)) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
Toast
|
Toast
|
||||||
.makeText(
|
.makeText(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.Note
|
|||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.amethyst.service.model.*
|
import com.vitorpamplona.amethyst.service.model.*
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class PollNoteViewModel {
|
class PollNoteViewModel {
|
||||||
@@ -16,7 +17,9 @@ class PollNoteViewModel {
|
|||||||
var valueMaximum: Int? = null
|
var valueMaximum: Int? = null
|
||||||
var valueMinimum: Int? = null
|
var valueMinimum: Int? = null
|
||||||
private var closedAt: Int? = null
|
private var closedAt: Int? = null
|
||||||
var consensusThreshold: Float? = null
|
var consensusThreshold: BigDecimal? = null
|
||||||
|
|
||||||
|
var totalZapped: BigDecimal = BigDecimal.ZERO
|
||||||
|
|
||||||
fun load(note: Note?) {
|
fun load(note: Note?) {
|
||||||
pollNote = note
|
pollNote = note
|
||||||
@@ -24,8 +27,10 @@ class PollNoteViewModel {
|
|||||||
pollOptions = pollEvent?.pollOptions()
|
pollOptions = pollEvent?.pollOptions()
|
||||||
valueMaximum = pollEvent?.getTagInt(VALUE_MAXIMUM)
|
valueMaximum = pollEvent?.getTagInt(VALUE_MAXIMUM)
|
||||||
valueMinimum = pollEvent?.getTagInt(VALUE_MINIMUM)
|
valueMinimum = pollEvent?.getTagInt(VALUE_MINIMUM)
|
||||||
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)
|
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
|
||||||
closedAt = pollEvent?.getTagInt(CLOSED_AT)
|
closedAt = pollEvent?.getTagInt(CLOSED_AT)
|
||||||
|
|
||||||
|
totalZapped = totalZapped()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
|
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
|
||||||
@@ -73,47 +78,45 @@ class PollNoteViewModel {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun optionVoteTally(op: Int): Float {
|
fun optionVoteTally(op: Int): BigDecimal {
|
||||||
val tally = zappedPollOptionAmount(op).toFloat().div(zappedVoteTotal())
|
return if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
return if (tally.isNaN()) { // catch div by 0
|
zappedPollOptionAmount(op).divide(totalZapped, 2, RoundingMode.HALF_UP)
|
||||||
0f
|
} else {
|
||||||
} else { tally }
|
BigDecimal.ZERO
|
||||||
}
|
|
||||||
|
|
||||||
private fun zappedVoteTotal(): Float {
|
|
||||||
var total = 0f
|
|
||||||
pollOptions?.keys?.forEach {
|
|
||||||
total += zappedPollOptionAmount(it).toFloat()
|
|
||||||
}
|
}
|
||||||
return total
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isPollOptionZappedBy(option: Int, user: User): Boolean {
|
fun isPollOptionZappedBy(option: Int, user: User): Boolean {
|
||||||
if (pollNote?.zaps?.any { it.key.author == user } == true) {
|
if (pollNote?.zaps?.any { it.key.author === user } == true) {
|
||||||
pollNote!!.zaps.mapNotNull { it.value?.event }
|
pollNote!!.zaps
|
||||||
.filterIsInstance<LnZapEvent>()
|
.any {
|
||||||
.map {
|
val event = it.value?.event as? LnZapEvent
|
||||||
val zappedOption = it.zappedPollOption()
|
event?.zappedPollOption() == option && event.zappedRequestAuthor() == user.pubkeyHex
|
||||||
if (zappedOption == option && it.zappedRequestAuthor() == user.pubkeyHex) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun zappedPollOptionAmount(option: Int): BigDecimal {
|
fun zappedPollOptionAmount(option: Int): BigDecimal {
|
||||||
return if (pollNote != null) {
|
return pollNote?.zaps?.values?.sumOf {
|
||||||
pollNote!!.zaps.mapNotNull { it.value?.event }
|
val event = it?.event as? LnZapEvent
|
||||||
.filterIsInstance<LnZapEvent>()
|
if (event?.zappedPollOption() == option) {
|
||||||
.mapNotNull {
|
event.amount ?: BigDecimal(0)
|
||||||
val zappedOption = it.zappedPollOption()
|
} else {
|
||||||
if (zappedOption == option) {
|
BigDecimal(0)
|
||||||
it.amount
|
}
|
||||||
} else { null }
|
} ?: BigDecimal(0)
|
||||||
}.sumOf { it }
|
}
|
||||||
} else {
|
|
||||||
BigDecimal(0)
|
fun totalZapped(): BigDecimal {
|
||||||
}
|
return pollNote?.zaps?.values?.sumOf {
|
||||||
|
val zapEvent = (it?.event as? LnZapEvent)
|
||||||
|
|
||||||
|
if (zapEvent?.zappedPollOption() != null) {
|
||||||
|
zapEvent.amount ?: BigDecimal(0)
|
||||||
|
} else {
|
||||||
|
BigDecimal(0)
|
||||||
|
}
|
||||||
|
} ?: BigDecimal(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -87,26 +86,28 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
|||||||
NewPostView({ wantsToQuote = null }, null, wantsToQuote, account)
|
NewPostView({ wantsToQuote = null }, null, wantsToQuote, account)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
modifier = Modifier
|
|
||||||
.padding(top = 8.dp)
|
Row(verticalAlignment = CenterVertically) {
|
||||||
.fillMaxWidth(),
|
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
ReplyReaction(baseNote, accountViewModel) {
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
wantsToReplyTo = baseNote
|
||||||
) {
|
}
|
||||||
ReplyReaction(baseNote, accountViewModel, Modifier.weight(1f)) {
|
|
||||||
wantsToReplyTo = baseNote
|
|
||||||
}
|
}
|
||||||
|
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
|
||||||
BoostReaction(baseNote, accountViewModel, Modifier.weight(1f)) {
|
BoostReaction(baseNote, accountViewModel) {
|
||||||
wantsToQuote = baseNote
|
wantsToQuote = baseNote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
|
||||||
|
LikeReaction(baseNote, accountViewModel)
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
|
||||||
|
ZapReaction(baseNote, accountViewModel)
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
|
||||||
|
ViewCountReaction(baseNote.idHex)
|
||||||
}
|
}
|
||||||
|
|
||||||
LikeReaction(baseNote, accountViewModel, Modifier.weight(1f))
|
|
||||||
|
|
||||||
ZapReaction(baseNote, accountViewModel, Modifier.weight(1f))
|
|
||||||
|
|
||||||
ViewCountReaction(baseNote, Modifier.weight(1f))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,14 +115,12 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
|||||||
fun ReplyReaction(
|
fun ReplyReaction(
|
||||||
baseNote: Note,
|
baseNote: Note,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
textModifier: Modifier = Modifier,
|
|
||||||
showCounter: Boolean = true,
|
showCounter: Boolean = true,
|
||||||
onPress: () -> Unit
|
onPress: () -> Unit
|
||||||
) {
|
) {
|
||||||
val repliesState by baseNote.live().replies.observeAsState()
|
val repliesState by baseNote.live().replies.observeAsState()
|
||||||
val replies = repliesState?.note?.replies ?: emptySet()
|
val replies = repliesState?.note?.replies ?: emptySet()
|
||||||
|
|
||||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -141,29 +140,32 @@ fun ReplyReaction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Icon(
|
ReplyIcon()
|
||||||
painter = painterResource(R.drawable.ic_comment),
|
|
||||||
null,
|
|
||||||
modifier = Modifier.size(15.dp),
|
|
||||||
tint = grayTint
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showCounter) {
|
if (showCounter) {
|
||||||
Text(
|
Text(
|
||||||
" ${showCount(replies.size)}",
|
" ${showCount(replies.size)}",
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = grayTint,
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
modifier = textModifier
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReplyIcon() {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_comment),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(15.dp),
|
||||||
|
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BoostReaction(
|
private fun BoostReaction(
|
||||||
baseNote: Note,
|
baseNote: Note,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
textModifier: Modifier = Modifier,
|
|
||||||
onQuotePress: () -> Unit
|
onQuotePress: () -> Unit
|
||||||
) {
|
) {
|
||||||
val boostsState by baseNote.live().boosts.observeAsState()
|
val boostsState by baseNote.live().boosts.observeAsState()
|
||||||
@@ -229,16 +231,14 @@ private fun BoostReaction(
|
|||||||
Text(
|
Text(
|
||||||
" ${showCount(boostedNote?.boosts?.size)}",
|
" ${showCount(boostedNote?.boosts?.size)}",
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
modifier = textModifier
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LikeReaction(
|
fun LikeReaction(
|
||||||
baseNote: Note,
|
baseNote: Note,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel
|
||||||
textModifier: Modifier = Modifier
|
|
||||||
) {
|
) {
|
||||||
val reactionsState by baseNote.live().reactions.observeAsState()
|
val reactionsState by baseNote.live().reactions.observeAsState()
|
||||||
val reactedNote = reactionsState?.note ?: return
|
val reactedNote = reactionsState?.note ?: return
|
||||||
@@ -287,8 +287,7 @@ fun LikeReaction(
|
|||||||
Text(
|
Text(
|
||||||
" ${showCount(reactedNote.reactions.size)}",
|
" ${showCount(reactedNote.reactions.size)}",
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
modifier = textModifier
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,13 +456,13 @@ fun ZapReaction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier) {
|
private fun ViewCountReaction(idHex: String) {
|
||||||
val uri = LocalUriHandler.current
|
val uri = LocalUriHandler.current
|
||||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
|
onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") }
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Outlined.BarChart,
|
imageVector = Icons.Outlined.BarChart,
|
||||||
@@ -473,10 +472,10 @@ private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(modifier = textModifier) {
|
Row() {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = ImageRequest.Builder(LocalContext.current)
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
.data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000")
|
.data("https://counter.amethyst.social/$idHex.svg?label=+&color=00000000")
|
||||||
.diskCachePolicy(CachePolicy.DISABLED)
|
.diskCachePolicy(CachePolicy.DISABLED)
|
||||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||||
.build(),
|
.build(),
|
||||||
|
|||||||
@@ -20,11 +20,9 @@ import com.vitorpamplona.amethyst.model.*
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ReplyInformation(replyTo: List<Note>?, mentions: List<String>, account: Account, navController: NavController) {
|
fun ReplyInformation(replyTo: List<Note>?, mentions: List<String>, account: Account, navController: NavController) {
|
||||||
val sortedMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
val dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||||
.toSet()
|
|
||||||
.sortedBy { account.userProfile().isFollowingCached(it) }
|
|
||||||
|
|
||||||
ReplyInformation(replyTo, sortedMentions, account) {
|
ReplyInformation(replyTo, dupMentions, account) {
|
||||||
navController.navigate("User/${it.pubkeyHex}")
|
navController.navigate("User/${it.pubkeyHex}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
|
|||||||
|
|
||||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||||
|
|
||||||
LaunchedEffect(key1 = zapSetCard) {
|
LaunchedEffect(key1 = zapSetCard.createdAt()) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead)
|
isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.LocalCacheState
|
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||||
@@ -15,7 +14,9 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
|||||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||||
|
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -30,7 +31,7 @@ import kotlin.time.measureTimedValue
|
|||||||
|
|
||||||
class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter)
|
class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter)
|
||||||
|
|
||||||
open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
|
open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||||
private val _feedContent = MutableStateFlow<CardFeedState>(CardFeedState.Loading)
|
private val _feedContent = MutableStateFlow<CardFeedState>(CardFeedState.Loading)
|
||||||
val feedContent = _feedContent.asStateFlow()
|
val feedContent = _feedContent.asStateFlow()
|
||||||
|
|
||||||
@@ -46,28 +47,28 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
|
|||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun refreshSuspended() {
|
private fun refreshSuspended() {
|
||||||
val notes = dataSource.loadTop()
|
val notes = localFilter.loadTop()
|
||||||
|
|
||||||
val thisAccount = (dataSource as? NotificationFeedFilter)?.account
|
val thisAccount = (localFilter as? NotificationFeedFilter)?.account
|
||||||
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
|
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
|
||||||
|
|
||||||
val oldNotesState = feedContent.value
|
val oldNotesState = _feedContent.value
|
||||||
if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) {
|
if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) {
|
||||||
val newCards = convertToCard(notes.minus(lastNotesCopy))
|
val newCards = convertToCard(notes.minus(lastNotesCopy))
|
||||||
if (newCards.isNotEmpty()) {
|
if (newCards.isNotEmpty()) {
|
||||||
lastNotes = notes
|
lastNotes = notes
|
||||||
lastAccount = (dataSource as? NotificationFeedFilter)?.account
|
lastAccount = (localFilter as? NotificationFeedFilter)?.account
|
||||||
updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed())
|
updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val cards = convertToCard(notes)
|
val cards = convertToCard(notes)
|
||||||
lastNotes = notes
|
lastNotes = notes
|
||||||
lastAccount = (dataSource as? NotificationFeedFilter)?.account
|
lastAccount = (localFilter as? NotificationFeedFilter)?.account
|
||||||
updateFeed(cards)
|
updateFeed(cards)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun convertToCard(notes: List<Note>): List<Card> {
|
private fun convertToCard(notes: Collection<Note>): List<Card> {
|
||||||
val reactionsPerEvent = mutableMapOf<Note, MutableList<Note>>()
|
val reactionsPerEvent = mutableMapOf<Note, MutableList<Note>>()
|
||||||
notes
|
notes
|
||||||
.filter { it.event is ReactionEvent }
|
.filter { it.event is ReactionEvent }
|
||||||
@@ -159,7 +160,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
|
|||||||
private fun updateFeed(notes: List<Card>) {
|
private fun updateFeed(notes: List<Card>) {
|
||||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val currentState = feedContent.value
|
val currentState = _feedContent.value
|
||||||
|
|
||||||
if (notes.isEmpty()) {
|
if (notes.isEmpty()) {
|
||||||
_feedContent.update { CardFeedState.Empty }
|
_feedContent.update { CardFeedState.Empty }
|
||||||
@@ -172,6 +173,28 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun refreshFromOldState(newItems: Set<Note>) {
|
||||||
|
val oldNotesState = _feedContent.value
|
||||||
|
|
||||||
|
val thisAccount = (localFilter as? NotificationFeedFilter)?.account
|
||||||
|
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
|
||||||
|
|
||||||
|
if (lastNotesCopy != null && localFilter is AdditiveFeedFilter && oldNotesState is CardFeedState.Loaded) {
|
||||||
|
val filteredNewList = localFilter.applyFilter(newItems)
|
||||||
|
val actuallyNew = filteredNewList.minus(lastNotesCopy)
|
||||||
|
|
||||||
|
val newCards = convertToCard(actuallyNew)
|
||||||
|
if (newCards.isNotEmpty()) {
|
||||||
|
lastNotes = lastNotesCopy + newItems
|
||||||
|
lastAccount = (localFilter as? NotificationFeedFilter)?.account
|
||||||
|
updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Refresh Everything
|
||||||
|
refreshSuspended()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTime::class)
|
@OptIn(ExperimentalTime::class)
|
||||||
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
||||||
// adds the time to perform the refresh into this delay
|
// adds the time to perform the refresh into this delay
|
||||||
@@ -181,13 +204,29 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
|
|||||||
}
|
}
|
||||||
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
|
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
|
||||||
}
|
}
|
||||||
|
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
|
||||||
|
|
||||||
fun invalidateData() {
|
fun invalidateData() {
|
||||||
bundler.invalidate()
|
bundler.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val cacheListener: (LocalCacheState) -> Unit = {
|
@OptIn(ExperimentalTime::class)
|
||||||
invalidateData()
|
fun invalidateInsertData(newItems: Set<Note>) {
|
||||||
|
bundlerInsert.invalidateList(newItems) {
|
||||||
|
val (value, elapsed) = measureTimedValue {
|
||||||
|
refreshFromOldState(it.flatten().toSet())
|
||||||
|
}
|
||||||
|
Log.d("Time", "${this.javaClass.simpleName} Card additive update $elapsed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
|
||||||
|
if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) {
|
||||||
|
invalidateInsertData(newNotes)
|
||||||
|
} else {
|
||||||
|
// Refresh Everything
|
||||||
|
invalidateData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ package com.vitorpamplona.amethyst.ui.screen
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.LocalCacheState
|
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||||
|
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
|
||||||
@@ -65,7 +66,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
|||||||
fun refreshSuspended() {
|
fun refreshSuspended() {
|
||||||
val notes = newListFromDataSource()
|
val notes = newListFromDataSource()
|
||||||
|
|
||||||
val oldNotesState = feedContent.value
|
val oldNotesState = _feedContent.value
|
||||||
if (oldNotesState is FeedState.Loaded) {
|
if (oldNotesState is FeedState.Loaded) {
|
||||||
// Using size as a proxy for has changed.
|
// Using size as a proxy for has changed.
|
||||||
if (notes != oldNotesState.feed.value) {
|
if (notes != oldNotesState.feed.value) {
|
||||||
@@ -79,7 +80,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
|||||||
private fun updateFeed(notes: List<Note>) {
|
private fun updateFeed(notes: List<Note>) {
|
||||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val currentState = feedContent.value
|
val currentState = _feedContent.value
|
||||||
if (notes.isEmpty()) {
|
if (notes.isEmpty()) {
|
||||||
_feedContent.update { FeedState.Empty }
|
_feedContent.update { FeedState.Empty }
|
||||||
} else if (currentState is FeedState.Loaded) {
|
} else if (currentState is FeedState.Loaded) {
|
||||||
@@ -91,18 +92,41 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun refreshFromOldState(newItems: Set<Note>) {
|
||||||
|
val oldNotesState = _feedContent.value
|
||||||
|
if (localFilter is AdditiveFeedFilter && oldNotesState is FeedState.Loaded) {
|
||||||
|
val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet())
|
||||||
|
updateFeed(newList)
|
||||||
|
} else {
|
||||||
|
// Refresh Everything
|
||||||
|
refreshSuspended()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
||||||
// adds the time to perform the refresh into this delay
|
// adds the time to perform the refresh into this delay
|
||||||
// holding off new updates in case of heavy refresh routines.
|
// holding off new updates in case of heavy refresh routines.
|
||||||
refreshSuspended()
|
refreshSuspended()
|
||||||
}
|
}
|
||||||
|
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
|
||||||
|
|
||||||
fun invalidateData() {
|
fun invalidateData() {
|
||||||
bundler.invalidate()
|
bundler.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val cacheListener: (LocalCacheState) -> Unit = {
|
fun invalidateInsertData(newItems: Set<Note>) {
|
||||||
invalidateData()
|
bundlerInsert.invalidateList(newItems) {
|
||||||
|
refreshFromOldState(it.flatten().toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
|
||||||
|
if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) {
|
||||||
|
invalidateInsertData(newNotes)
|
||||||
|
} else {
|
||||||
|
// Refresh Everything
|
||||||
|
invalidateData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.screen
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.LocalCacheState
|
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||||
@@ -32,7 +31,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
|
|||||||
private fun refreshSuspended() {
|
private fun refreshSuspended() {
|
||||||
val notes = dataSource.loadTop()
|
val notes = dataSource.loadTop()
|
||||||
|
|
||||||
val oldNotesState = feedContent.value
|
val oldNotesState = _feedContent.value
|
||||||
if (oldNotesState is LnZapFeedState.Loaded) {
|
if (oldNotesState is LnZapFeedState.Loaded) {
|
||||||
// Using size as a proxy for has changed.
|
// Using size as a proxy for has changed.
|
||||||
if (notes != oldNotesState.feed.value) {
|
if (notes != oldNotesState.feed.value) {
|
||||||
@@ -46,7 +45,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
|
|||||||
private fun updateFeed(notes: List<Pair<Note, Note>>) {
|
private fun updateFeed(notes: List<Pair<Note, Note>>) {
|
||||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val currentState = feedContent.value
|
val currentState = _feedContent.value
|
||||||
if (notes.isEmpty()) {
|
if (notes.isEmpty()) {
|
||||||
_feedContent.update { LnZapFeedState.Empty }
|
_feedContent.update { LnZapFeedState.Empty }
|
||||||
} else if (currentState is LnZapFeedState.Loaded) {
|
} else if (currentState is LnZapFeedState.Loaded) {
|
||||||
@@ -68,7 +67,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
|
|||||||
bundler.invalidate()
|
bundler.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val cacheListener: (LocalCacheState) -> Unit = {
|
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
|
||||||
invalidateData()
|
invalidateData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.LocalCacheState
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||||
@@ -36,7 +36,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
|
|||||||
private fun refreshSuspended() {
|
private fun refreshSuspended() {
|
||||||
val notes = dataSource.loadTop()
|
val notes = dataSource.loadTop()
|
||||||
|
|
||||||
val oldNotesState = feedContent.value
|
val oldNotesState = _feedContent.value
|
||||||
if (oldNotesState is UserFeedState.Loaded) {
|
if (oldNotesState is UserFeedState.Loaded) {
|
||||||
// Using size as a proxy for has changed.
|
// Using size as a proxy for has changed.
|
||||||
if (notes != oldNotesState.feed.value) {
|
if (notes != oldNotesState.feed.value) {
|
||||||
@@ -50,7 +50,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
|
|||||||
private fun updateFeed(notes: List<User>) {
|
private fun updateFeed(notes: List<User>) {
|
||||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val currentState = feedContent.value
|
val currentState = _feedContent.value
|
||||||
if (notes.isEmpty()) {
|
if (notes.isEmpty()) {
|
||||||
_feedContent.update { UserFeedState.Empty }
|
_feedContent.update { UserFeedState.Empty }
|
||||||
} else if (currentState is UserFeedState.Loaded) {
|
} else if (currentState is UserFeedState.Loaded) {
|
||||||
@@ -72,7 +72,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
|
|||||||
bundler.invalidate()
|
bundler.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val cacheListener: (LocalCacheState) -> Unit = {
|
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
|
||||||
invalidateData()
|
invalidateData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-14
@@ -1,12 +1,14 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Tab
|
import androidx.compose.material.Tab
|
||||||
import androidx.compose.material.TabRow
|
import androidx.compose.material.TabRow
|
||||||
import androidx.compose.material.TabRowDefaults
|
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -18,10 +20,6 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
|
||||||
import com.google.accompanist.pager.HorizontalPager
|
|
||||||
import com.google.accompanist.pager.pagerTabIndicatorOffset
|
|
||||||
import com.google.accompanist.pager.rememberPagerState
|
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
|
||||||
@@ -30,7 +28,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel
|
import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalPagerApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavController) {
|
fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavController) {
|
||||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||||
@@ -57,13 +55,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavCon
|
|||||||
|
|
||||||
TabRow(
|
TabRow(
|
||||||
backgroundColor = MaterialTheme.colors.background,
|
backgroundColor = MaterialTheme.colors.background,
|
||||||
selectedTabIndex = pagerState.currentPage,
|
selectedTabIndex = pagerState.currentPage
|
||||||
indicator = { tabPositions ->
|
|
||||||
TabRowDefaults.Indicator(
|
|
||||||
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
|
|
||||||
color = MaterialTheme.colors.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
Tab(
|
Tab(
|
||||||
selected = pagerState.currentPage == 0,
|
selected = pagerState.currentPage == 0,
|
||||||
@@ -80,7 +72,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavCon
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
HorizontalPager(count = 2, state = pagerState) {
|
HorizontalPager(pageCount = 2, state = pagerState) {
|
||||||
when (pagerState.currentPage) {
|
when (pagerState.currentPage) {
|
||||||
0 -> FeedView(privateFeedViewModel, accountViewModel, navController, null)
|
0 -> FeedView(privateFeedViewModel, accountViewModel, navController, null)
|
||||||
1 -> FeedView(publicFeedViewModel, accountViewModel, navController, null)
|
1 -> FeedView(publicFeedViewModel, accountViewModel, navController, null)
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ fun ChannelScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val channelScreenModel: NewPostViewModel = viewModel()
|
val channelScreenModel: NewPostViewModel = viewModel()
|
||||||
|
|
||||||
|
channelScreenModel.account = account
|
||||||
|
|
||||||
if (account != null && channelId != null) {
|
if (account != null && channelId != null) {
|
||||||
val replyTo = remember { mutableStateOf<Note?>(null) }
|
val replyTo = remember { mutableStateOf<Note?>(null) }
|
||||||
|
|
||||||
|
|||||||
+6
-14
@@ -1,5 +1,6 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
@@ -7,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.material.DropdownMenu
|
import androidx.compose.material.DropdownMenu
|
||||||
import androidx.compose.material.DropdownMenuItem
|
import androidx.compose.material.DropdownMenuItem
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.Icon
|
||||||
@@ -14,7 +17,6 @@ import androidx.compose.material.IconButton
|
|||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Tab
|
import androidx.compose.material.Tab
|
||||||
import androidx.compose.material.TabRow
|
import androidx.compose.material.TabRow
|
||||||
import androidx.compose.material.TabRowDefaults
|
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
@@ -37,10 +39,6 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
|
||||||
import com.google.accompanist.pager.HorizontalPager
|
|
||||||
import com.google.accompanist.pager.pagerTabIndicatorOffset
|
|
||||||
import com.google.accompanist.pager.rememberPagerState
|
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
|
||||||
@@ -50,7 +48,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
|
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalPagerApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavController) {
|
fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavController) {
|
||||||
val pagerState = rememberPagerState()
|
val pagerState = rememberPagerState()
|
||||||
@@ -68,13 +66,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
|
|||||||
Box(Modifier.fillMaxWidth()) {
|
Box(Modifier.fillMaxWidth()) {
|
||||||
TabRow(
|
TabRow(
|
||||||
backgroundColor = MaterialTheme.colors.background,
|
backgroundColor = MaterialTheme.colors.background,
|
||||||
selectedTabIndex = pagerState.currentPage,
|
selectedTabIndex = pagerState.currentPage
|
||||||
indicator = { tabPositions ->
|
|
||||||
TabRowDefaults.Indicator(
|
|
||||||
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
|
|
||||||
color = MaterialTheme.colors.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
Tab(
|
Tab(
|
||||||
selected = pagerState.currentPage == 0,
|
selected = pagerState.currentPage == 0,
|
||||||
@@ -115,7 +107,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalPager(count = 2, state = pagerState) {
|
HorizontalPager(pageCount = 2, state = pagerState) {
|
||||||
when (pagerState.currentPage) {
|
when (pagerState.currentPage) {
|
||||||
0 -> TabKnown(accountViewModel, navController, markKnownAsRead)
|
0 -> TabKnown(accountViewModel, navController, markKnownAsRead)
|
||||||
1 -> TabNew(accountViewModel, navController, markNewAsRead)
|
1 -> TabNew(accountViewModel, navController, markNewAsRead)
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val chatRoomScreenModel: NewPostViewModel = viewModel()
|
val chatRoomScreenModel: NewPostViewModel = viewModel()
|
||||||
|
|
||||||
|
chatRoomScreenModel.account = account
|
||||||
|
|
||||||
if (account != null && userId != null) {
|
if (account != null && userId != null) {
|
||||||
val replyTo = remember { mutableStateOf<Note?>(null) }
|
val replyTo = remember { mutableStateOf<Note?>(null) }
|
||||||
|
|
||||||
|
|||||||
+6
-14
@@ -1,12 +1,14 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Tab
|
import androidx.compose.material.Tab
|
||||||
import androidx.compose.material.TabRow
|
import androidx.compose.material.TabRow
|
||||||
import androidx.compose.material.TabRowDefaults
|
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -17,17 +19,13 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
|
||||||
import com.google.accompanist.pager.HorizontalPager
|
|
||||||
import com.google.accompanist.pager.pagerTabIndicatorOffset
|
|
||||||
import com.google.accompanist.pager.rememberPagerState
|
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.ui.dal.HiddenAccountsFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.HiddenAccountsFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel
|
import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.UserFeedView
|
import com.vitorpamplona.amethyst.ui.screen.UserFeedView
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalPagerApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavController) {
|
fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavController) {
|
||||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||||
@@ -45,13 +43,7 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavCont
|
|||||||
|
|
||||||
TabRow(
|
TabRow(
|
||||||
backgroundColor = MaterialTheme.colors.background,
|
backgroundColor = MaterialTheme.colors.background,
|
||||||
selectedTabIndex = pagerState.currentPage,
|
selectedTabIndex = pagerState.currentPage
|
||||||
indicator = { tabPositions ->
|
|
||||||
TabRowDefaults.Indicator(
|
|
||||||
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
|
|
||||||
color = MaterialTheme.colors.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
Tab(
|
Tab(
|
||||||
selected = pagerState.currentPage == 0,
|
selected = pagerState.currentPage == 0,
|
||||||
@@ -61,7 +53,7 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavCont
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
HorizontalPager(count = 1, state = pagerState) {
|
HorizontalPager(pageCount = 1, state = pagerState) {
|
||||||
when (pagerState.currentPage) {
|
when (pagerState.currentPage) {
|
||||||
0 -> UserFeedView(feedViewModel, accountViewModel, navController)
|
0 -> UserFeedView(feedViewModel, accountViewModel, navController)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Tab
|
import androidx.compose.material.Tab
|
||||||
import androidx.compose.material.TabRow
|
import androidx.compose.material.TabRow
|
||||||
import androidx.compose.material.TabRowDefaults
|
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
@@ -23,10 +25,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
|
||||||
import com.google.accompanist.pager.HorizontalPager
|
|
||||||
import com.google.accompanist.pager.PagerState
|
|
||||||
import com.google.accompanist.pager.pagerTabIndicatorOffset
|
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||||
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
|
||||||
@@ -39,7 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
|
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalPagerApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
homeFeedViewModel: NostrHomeFeedViewModel,
|
homeFeedViewModel: NostrHomeFeedViewModel,
|
||||||
@@ -90,13 +88,7 @@ fun HomeScreen(
|
|||||||
) {
|
) {
|
||||||
TabRow(
|
TabRow(
|
||||||
backgroundColor = MaterialTheme.colors.background,
|
backgroundColor = MaterialTheme.colors.background,
|
||||||
selectedTabIndex = pagerState.currentPage,
|
selectedTabIndex = pagerState.currentPage
|
||||||
indicator = { tabPositions ->
|
|
||||||
TabRowDefaults.Indicator(
|
|
||||||
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
|
|
||||||
color = MaterialTheme.colors.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
Tab(
|
Tab(
|
||||||
selected = pagerState.currentPage == 0,
|
selected = pagerState.currentPage == 0,
|
||||||
@@ -114,7 +106,7 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
HorizontalPager(count = 2, state = pagerState) {
|
HorizontalPager(pageCount = 2, state = pagerState) {
|
||||||
when (pagerState.currentPage) {
|
when (pagerState.currentPage) {
|
||||||
0 -> FeedView(homeFeedViewModel, accountViewModel, navController, Route.Home.base + "Follows", ScrollStateKeys.HOME_FOLLOWS, scrollToTop)
|
0 -> FeedView(homeFeedViewModel, accountViewModel, navController, Route.Home.base + "Follows", ScrollStateKeys.HOME_FOLLOWS, scrollToTop)
|
||||||
1 -> FeedView(repliesFeedViewModel, accountViewModel, navController, Route.Home.base + "FollowsReplies", ScrollStateKeys.HOME_REPLIES, scrollToTop)
|
1 -> FeedView(repliesFeedViewModel, accountViewModel, navController, Route.Home.base + "FollowsReplies", ScrollStateKeys.HOME_REPLIES, scrollToTop)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -19,6 +20,7 @@ import androidx.compose.material.rememberScaffoldState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
@@ -34,10 +36,12 @@ import com.vitorpamplona.amethyst.ui.navigation.Route
|
|||||||
import com.vitorpamplona.amethyst.ui.navigation.currentRoute
|
import com.vitorpamplona.amethyst.ui.navigation.currentRoute
|
||||||
import com.vitorpamplona.amethyst.ui.screen.AccountState
|
import com.vitorpamplona.amethyst.ui.screen.AccountState
|
||||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) {
|
fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
|
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
|
||||||
val sheetState = rememberModalBottomSheetState(
|
val sheetState = rememberModalBottomSheetState(
|
||||||
@@ -64,6 +68,9 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
|
|||||||
},
|
},
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
DrawerContent(navController, scaffoldState, sheetState, accountViewModel)
|
DrawerContent(navController, scaffoldState, sheetState, accountViewModel)
|
||||||
|
BackHandler(enabled = scaffoldState.drawerState.isOpen) {
|
||||||
|
coroutineScope.launch { scaffoldState.drawerState.close() }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingButtons(navController, accountStateViewModel)
|
FloatingButtons(navController, accountStateViewModel)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import android.net.Uri
|
|||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.gestures.scrollBy
|
import androidx.compose.foundation.gestures.scrollBy
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.ClickableText
|
import androidx.compose.foundation.text.ClickableText
|
||||||
@@ -48,10 +50,6 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
|
||||||
import com.google.accompanist.pager.HorizontalPager
|
|
||||||
import com.google.accompanist.pager.pagerTabIndicatorOffset
|
|
||||||
import com.google.accompanist.pager.rememberPagerState
|
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
@@ -70,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
|
|||||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||||
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
|
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.figureOutMimeType
|
||||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter
|
||||||
@@ -98,7 +97,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
||||||
@OptIn(ExperimentalPagerApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) {
|
fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) {
|
||||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||||
@@ -187,12 +186,6 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
|||||||
ScrollableTabRow(
|
ScrollableTabRow(
|
||||||
backgroundColor = MaterialTheme.colors.background,
|
backgroundColor = MaterialTheme.colors.background,
|
||||||
selectedTabIndex = pagerState.currentPage,
|
selectedTabIndex = pagerState.currentPage,
|
||||||
indicator = { tabPositions ->
|
|
||||||
TabRowDefaults.Indicator(
|
|
||||||
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
|
|
||||||
color = MaterialTheme.colors.primary
|
|
||||||
)
|
|
||||||
},
|
|
||||||
edgePadding = 8.dp,
|
edgePadding = 8.dp,
|
||||||
modifier = Modifier.onSizeChanged {
|
modifier = Modifier.onSizeChanged {
|
||||||
tabsSize = it
|
tabsSize = it
|
||||||
@@ -268,7 +261,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
count = 8,
|
pageCount = 8,
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
modifier = with(LocalDensity.current) {
|
modifier = with(LocalDensity.current) {
|
||||||
Modifier.height((columnSize.height - tabsSize.height).toDp())
|
Modifier.height((columnSize.height - tabsSize.height).toDp())
|
||||||
@@ -409,8 +402,9 @@ private fun ProfileHeader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (zoomImageDialogOpen) {
|
val profilePic = baseUser.profilePicture()
|
||||||
ZoomableImageDialog(baseUser.profilePicture()!!, onDismiss = { zoomImageDialogOpen = false })
|
if (zoomImageDialogOpen && profilePic != null) {
|
||||||
|
ZoomableImageDialog(figureOutMimeType(profilePic), onDismiss = { zoomImageDialogOpen = false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,7 +492,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
|
|||||||
|
|
||||||
userBadge.acceptedBadges?.let { note ->
|
userBadge.acceptedBadges?.let { note ->
|
||||||
(note.event as? BadgeProfilesEvent)?.let { event ->
|
(note.event as? BadgeProfilesEvent)?.let { event ->
|
||||||
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
|
FlowRow(verticalArrangement = Arrangement.Center, modifier = Modifier.padding(vertical = 5.dp)) {
|
||||||
event.badgeAwardEvents().forEach { badgeAwardEvent ->
|
event.badgeAwardEvents().forEach { badgeAwardEvent ->
|
||||||
val baseNote = LocalCache.notes[badgeAwardEvent]
|
val baseNote = LocalCache.notes[badgeAwardEvent]
|
||||||
if (baseNote != null) {
|
if (baseNote != null) {
|
||||||
@@ -655,6 +649,7 @@ fun BadgeThumb(
|
|||||||
if (image == null) {
|
if (image == null) {
|
||||||
RobohashAsyncImage(
|
RobohashAsyncImage(
|
||||||
robot = "authornotfound",
|
robot = "authornotfound",
|
||||||
|
robotSize = size,
|
||||||
contentDescription = stringResource(R.string.unknown_author),
|
contentDescription = stringResource(R.string.unknown_author),
|
||||||
modifier = pictureModifier
|
modifier = pictureModifier
|
||||||
.width(size)
|
.width(size)
|
||||||
@@ -664,6 +659,7 @@ fun BadgeThumb(
|
|||||||
} else {
|
} else {
|
||||||
RobohashFallbackAsyncImage(
|
RobohashFallbackAsyncImage(
|
||||||
robot = note.idHex,
|
robot = note.idHex,
|
||||||
|
robotSize = size,
|
||||||
model = image,
|
model = image,
|
||||||
contentDescription = stringResource(id = R.string.profile_image),
|
contentDescription = stringResource(id = R.string.profile_image),
|
||||||
modifier = pictureModifier
|
modifier = pictureModifier
|
||||||
@@ -712,7 +708,7 @@ private fun DrawBanner(baseUser: User) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (zoomImageDialogOpen) {
|
if (zoomImageDialogOpen) {
|
||||||
ZoomableImageDialog(imageUrl = banner, onDismiss = { zoomImageDialogOpen = false })
|
ZoomableImageDialog(imageUrl = figureOutMimeType(banner), onDismiss = { zoomImageDialogOpen = false })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Image(
|
Image(
|
||||||
|
|||||||
@@ -263,9 +263,30 @@
|
|||||||
<string name="wallet_connect_service_secret_placeholder">nsec / hex privát kulcs</string>
|
<string name="wallet_connect_service_secret_placeholder">nsec / hex privát kulcs</string>
|
||||||
|
|
||||||
<string name="pledge_amount_in_sats">Hozzájárulás összege sats-ban</string>
|
<string name="pledge_amount_in_sats">Hozzájárulás összege sats-ban</string>
|
||||||
|
<string name="post_poll">Szavazás Létrehozása</string>
|
||||||
|
<string name="poll_heading_required">Szükséges mezők:</string>
|
||||||
|
<string name="poll_zap_recipients">Zap-et kapják</string>
|
||||||
|
<string name="poll_primary_description">Szavazás elsődleges leírása…</string>
|
||||||
|
<string name="poll_option_index">Szavazás %s megoszlása</string>
|
||||||
|
<string name="poll_option_description">Szavazás opcióinak leírása</string>
|
||||||
|
<string name="poll_heading_optional">Kiegészítő mezők:</string>
|
||||||
|
<string name="poll_zap_value_min">Zap minimum</string>
|
||||||
|
<string name="poll_zap_value_max">Zap maximum</string>
|
||||||
|
<string name="poll_consensus_threshold">Konszenzus</string>
|
||||||
|
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||||
|
<string name="poll_closing_time">Szavazás lezárása</string>
|
||||||
|
<string name="poll_closing_time_days">napok</string>
|
||||||
|
<string name="poll_is_closed">A szavazásra nem lehet már új szavazatot leadni</string>
|
||||||
|
<string name="poll_zap_amount">Zap összege</string>
|
||||||
|
<string name="one_vote_per_user_on_atomic_votes">Az ilyen típusú szavazásokon felhasználónként csak egy szavazat engedélyezett</string>
|
||||||
|
|
||||||
|
|
||||||
<string name="looking_for_event">"%1$s esemény keresése"</string>
|
<string name="looking_for_event">"%1$s esemény keresése"</string>
|
||||||
|
|
||||||
<string name="custom_zaps_add_a_message">Nyilvános üzenet hozzáadása</string>
|
<string name="custom_zaps_add_a_message">Nyilvános üzenet hozzáadása</string>
|
||||||
<string name="custom_zaps_add_a_message_example">Köszönöm a kemény munkát!</string>
|
<string name="custom_zaps_add_a_message_example">Köszönöm a kemény munkát!</string>
|
||||||
|
|
||||||
|
<string name="lightning_create_and_add_invoice">Létrehoz és Hozzáad</string>
|
||||||
|
<string name="poll_author_no_vote">A szavazás létrehozója sajátjára nem szavazhat.</string>
|
||||||
|
<string name="poll_hashtag" translatable="false">#zappoll</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -226,4 +226,63 @@
|
|||||||
<string name="account_switch_has_private_key">Heeft privésleutel</string>
|
<string name="account_switch_has_private_key">Heeft privésleutel</string>
|
||||||
<string name="account_switch_pubkey_only">Alleen lezen, geen privésleutel</string>
|
<string name="account_switch_pubkey_only">Alleen lezen, geen privésleutel</string>
|
||||||
<string name="back">Terug</string>
|
<string name="back">Terug</string>
|
||||||
|
<string name="report_dialog_spam">Spam of oplichting</string>
|
||||||
|
<string name="report_dialog_profanity">Godslastering of haatdragend gedrag</string>
|
||||||
|
<string name="report_dialog_impersonation">Kwaadwillige imitatie</string>
|
||||||
|
<string name="report_dialog_nudity">Naaktheid of grafische inhoud</string>
|
||||||
|
<string name="report_dialog_illegal">Illegaal gedrag</string>
|
||||||
|
<string name="report_dialog_blocking_a_user">Als u een gebruiker blokkeert, wordt zijn inhoud in uw app verborgen. Uw notes zijn nog steeds publiekelijk zichtbaar, ook voor mensen die u blokkeert. Geblokkeerde gebruikers worden vermeld in het scherm Beveiligingsfilters.</string>
|
||||||
|
<string name="report_dialog_block_hide_user_btn"><![CDATA[Blokkeer & verberg gebruiker]]></string>
|
||||||
|
<string name="report_dialog_report_btn">Misbruik melden</string>
|
||||||
|
<string name="report_dialog_reminder_public">Alle geplaatste rapporteringen zijn openbaar.</string>
|
||||||
|
<string name="report_dialog_additional_reason_placeholder">Geef optioneel extra context over uw rapportering...</string>
|
||||||
|
<string name="report_dialog_additional_reason_label">Extra context</string>
|
||||||
|
<string name="report_dialog_select_reason_label">Reden</string>
|
||||||
|
<string name="report_dialog_select_reason_placeholder">Selecteer een reden…</string>
|
||||||
|
<string name="report_dialog_post_report_btn">Rapporteer</string>
|
||||||
|
<string name="report_dialog_title">Blokkeer en rapporteer</string>
|
||||||
|
<string name="block_only">Blokkeren</string>
|
||||||
|
|
||||||
|
<string name="bookmarks">Bladwijzers</string>
|
||||||
|
<string name="private_bookmarks">Privé bladwijzers</string>
|
||||||
|
<string name="public_bookmarks">PPublieke Bladwijzers</string>
|
||||||
|
<string name="add_to_private_bookmarks">Toevoegen aan privé bladwijzers</string>
|
||||||
|
<string name="add_to_public_bookmarks">Toevoegen aan publieke bladwijzers</string>
|
||||||
|
<string name="remove_from_private_bookmarks">Verwijderen uit privé bladwijzers</string>
|
||||||
|
<string name="remove_from_public_bookmarks">Verwijderen uit publieke bladwijzers</string>
|
||||||
|
|
||||||
|
<string name="wallet_connect_service">Wallet Connect Service</string>
|
||||||
|
<string name="wallet_connect_service_explainer">Machtigt een Nostr Secret om zaps te betalen zonder de app te verlaten. Houd het geheim veilig en gebruik indien mogelijk een privé relays.</string>
|
||||||
|
<string name="wallet_connect_service_pubkey">Wallet Connect publieke sleutel</string>
|
||||||
|
<string name="wallet_connect_service_relay">Wallet Connect Relay</string>
|
||||||
|
<string name="wallet_connect_service_secret">Wallet Connect Secret</string>
|
||||||
|
<string name="wallet_connect_service_show_secret">Toon secret sleutel</string>
|
||||||
|
<string name="wallet_connect_service_secret_placeholder">nsec / hex privésleutel</string>
|
||||||
|
|
||||||
|
<string name="pledge_amount_in_sats">Bedrag in sats</string>
|
||||||
|
<string name="post_poll">Verzend poll</string>
|
||||||
|
<string name="poll_heading_required">Verplichte velden:</string>
|
||||||
|
<string name="poll_zap_recipients">Zap ontvangers</string>
|
||||||
|
<string name="poll_primary_description">Primaire poll beschrijving…</string>
|
||||||
|
<string name="poll_option_index">Optie %s</string>
|
||||||
|
<string name="poll_option_description">Poll optie beschrijvingen</string>
|
||||||
|
<string name="poll_heading_optional">Optionele velden:</string>
|
||||||
|
<string name="poll_zap_value_min">Zap minimum</string>
|
||||||
|
<string name="poll_zap_value_max">Zap maximum</string>
|
||||||
|
<string name="poll_consensus_threshold">Consensus</string>
|
||||||
|
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||||
|
<string name="poll_closing_time">Sluit na</string>
|
||||||
|
<string name="poll_closing_time_days">dagen</string>
|
||||||
|
<string name="poll_is_closed">Poll is gesloten voor nieuwe stemmen</string>
|
||||||
|
<string name="poll_zap_amount">Zap bedrag</string>
|
||||||
|
<string name="one_vote_per_user_on_atomic_votes">Slechts één stem per gebruiker is toegestaan bij dit type peiling.</string>
|
||||||
|
|
||||||
|
<string name="looking_for_event">"Zoeken naar event %1$s"</string>
|
||||||
|
|
||||||
|
<string name="custom_zaps_add_a_message">Een openbaar bericht toevoegen</string>
|
||||||
|
<string name="custom_zaps_add_a_message_example">Bedankt voor al jullie werk!</string>
|
||||||
|
|
||||||
|
<string name="lightning_create_and_add_invoice">Maken en toevoegen</string>
|
||||||
|
<string name="poll_author_no_vote">Poll auteurs kunnen niet stemmen in hun eigen poll.</string>
|
||||||
|
<string name="poll_hashtag" translatable="false">#zappoll</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -292,4 +292,9 @@
|
|||||||
<string name="lightning_create_and_add_invoice">Create and Add</string>
|
<string name="lightning_create_and_add_invoice">Create and Add</string>
|
||||||
<string name="poll_author_no_vote">Poll authors can\'t vote in their own polls.</string>
|
<string name="poll_author_no_vote">Poll authors can\'t vote in their own polls.</string>
|
||||||
<string name="poll_hashtag" translatable="false">#zappoll</string>
|
<string name="poll_hashtag" translatable="false">#zappoll</string>
|
||||||
|
|
||||||
|
|
||||||
|
<string name="hash_verification_passed">Image is the same since the post</string>
|
||||||
|
<string name="hash_verification_failed">Image has changed. The author might not have seen the change</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
+3
-3
@@ -2,7 +2,7 @@ buildscript {
|
|||||||
ext {
|
ext {
|
||||||
fragment_version = "1.5.6"
|
fragment_version = "1.5.6"
|
||||||
lifecycle_version = '2.6.1'
|
lifecycle_version = '2.6.1'
|
||||||
compose_ui_version = '1.5.0-alpha01'
|
compose_ui_version = '1.5.0-alpha02'
|
||||||
nav_version = "2.5.3"
|
nav_version = "2.5.3"
|
||||||
room_version = "2.4.3"
|
room_version = "2.4.3"
|
||||||
accompanist_version = '0.30.0'
|
accompanist_version = '0.30.0'
|
||||||
@@ -10,8 +10,8 @@ buildscript {
|
|||||||
}
|
}
|
||||||
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application' version '7.4.2' apply false
|
id 'com.android.application' version '8.0.0' apply false
|
||||||
id 'com.android.library' version '7.4.2' apply false
|
id 'com.android.library' version '8.0.0' apply false
|
||||||
id 'org.jetbrains.kotlin.android' version '1.8.10' apply false
|
id 'org.jetbrains.kotlin.android' version '1.8.10' apply false
|
||||||
id 'org.jetbrains.kotlin.jvm' version '1.8.10' apply false
|
id 'org.jetbrains.kotlin.jvm' version '1.8.10' apply false
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -21,4 +21,6 @@ kotlin.code.style=official
|
|||||||
# resources declared in the library itself and none from the library's dependencies,
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
# thereby reducing the size of the R class for that library
|
# thereby reducing the size of the R class for that library
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
android.enableR8.fullMode=true
|
android.enableR8.fullMode=true
|
||||||
|
android.defaults.buildfeatures.buildconfig=true
|
||||||
|
android.nonFinalResIds=false
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
#Wed Jan 04 09:23:50 EST 2023
|
#Wed Jan 04 09:23:50 EST 2023
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
Reference in New Issue
Block a user