Base code for Amethyst
17
.gitignore
vendored
@ -1,3 +1,20 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
|
||||||
# Built application files
|
# Built application files
|
||||||
*.apk
|
*.apk
|
||||||
*.aar
|
*.aar
|
||||||
|
3
.idea/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
6
.idea/compiler.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<bytecodeTargetLevel target="11" />
|
||||||
|
</component>
|
||||||
|
</project>
|
37
.idea/inspectionProfiles/Project_Default.xml
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
10
.idea/misc.xml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="1.8" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectType">
|
||||||
|
<option name="id" value="Android" />
|
||||||
|
</component>
|
||||||
|
</project>
|
BIN
amethyst.png
Normal file
After Width: | Height: | Size: 8.4 KiB |
1
app/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/build
|
101
app/build.gradle
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'com.vitorpamplona.amethyst'
|
||||||
|
compileSdk 33
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.vitorpamplona.amethyst"
|
||||||
|
minSdk 26
|
||||||
|
targetSdk 33
|
||||||
|
versionCode 1
|
||||||
|
versionName "0.1"
|
||||||
|
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_11
|
||||||
|
targetCompatibility JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '11'
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose true
|
||||||
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion '1.3.2'
|
||||||
|
}
|
||||||
|
packagingOptions {
|
||||||
|
resources {
|
||||||
|
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'androidx.core:core-ktx:1.9.0'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
|
||||||
|
implementation 'androidx.activity:activity-compose:1.6.1'
|
||||||
|
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||||
|
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||||
|
implementation 'androidx.compose.material:material:1.3.1'
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
implementation("androidx.navigation:navigation-compose:$nav_version")
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha03'
|
||||||
|
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-alpha03'
|
||||||
|
|
||||||
|
// Input
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1'
|
||||||
|
|
||||||
|
// Swipe Refresh
|
||||||
|
implementation 'com.google.accompanist:accompanist-swiperefresh:0.24.13-rc'
|
||||||
|
|
||||||
|
// Load images from the web.
|
||||||
|
implementation "io.coil-kt:coil-compose:2.2.2"
|
||||||
|
|
||||||
|
// Bitcoin secp256k1 bindings to Android
|
||||||
|
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.0'
|
||||||
|
|
||||||
|
// Nostr Base Protocol
|
||||||
|
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
|
||||||
|
exclude group:'fr.acinq.secp256k1'
|
||||||
|
exclude module: 'guava'
|
||||||
|
exclude module: 'guava-testlib'
|
||||||
|
}
|
||||||
|
|
||||||
|
implementation 'com.squareup.retrofit2:converter-gson:2.8.1'
|
||||||
|
// Websockets API
|
||||||
|
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
|
||||||
|
|
||||||
|
// Json Serialization
|
||||||
|
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.1'
|
||||||
|
|
||||||
|
// Rendering clickable text
|
||||||
|
implementation "com.google.accompanist:accompanist-flowlayout:0.28.0"
|
||||||
|
|
||||||
|
// link preview
|
||||||
|
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
|
||||||
|
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha03'
|
||||||
|
|
||||||
|
testImplementation 'junit:junit:4.13.2'
|
||||||
|
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||||
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||||
|
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
|
||||||
|
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
|
||||||
|
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version"
|
||||||
|
}
|
21
app/proguard-rules.pro
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
@ -0,0 +1,24 @@
|
|||||||
|
package com.vitorpamplona.amethyst
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ExampleInstrumentedTest {
|
||||||
|
@Test
|
||||||
|
fun useAppContext() {
|
||||||
|
// Context of the app under test.
|
||||||
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
assertEquals("com.vitorpamplona.amethyst", appContext.packageName)
|
||||||
|
}
|
||||||
|
}
|
35
app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@drawable/amethyst_logo"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@drawable/amethyst_logo"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Amethyst"
|
||||||
|
tools:targetApi="31">
|
||||||
|
<activity
|
||||||
|
android:name=".ui.MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.Amethyst">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.lib_name"
|
||||||
|
android:value="" />
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
22
app/src/main/java/com/vitorpamplona/amethyst/KeyStorage.kt
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package com.vitorpamplona.amethyst
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKeys
|
||||||
|
|
||||||
|
class KeyStorage {
|
||||||
|
|
||||||
|
fun encryptedPreferences(context: Context): EncryptedSharedPreferences {
|
||||||
|
val secretKey: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
|
||||||
|
val preferencesName = "secret_keeper"
|
||||||
|
|
||||||
|
return EncryptedSharedPreferences.create(
|
||||||
|
preferencesName,
|
||||||
|
secretKey,
|
||||||
|
context,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
) as EncryptedSharedPreferences
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,92 @@
|
|||||||
|
package com.vitorpamplona.amethyst.model
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.relays.Client
|
||||||
|
import nostr.postr.Persona
|
||||||
|
import nostr.postr.events.TextNoteEvent
|
||||||
|
import nostr.postr.toHex
|
||||||
|
|
||||||
|
class Account(val loggedIn: Persona) {
|
||||||
|
var seeReplies: Boolean = true
|
||||||
|
|
||||||
|
fun userProfile(): User {
|
||||||
|
return LocalCache.getOrCreateUser(loggedIn.pubKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isWriteable(): Boolean {
|
||||||
|
return loggedIn.privKey != null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reactTo(note: Note) {
|
||||||
|
if (!isWriteable()) return
|
||||||
|
|
||||||
|
note.event?.let {
|
||||||
|
val event = ReactionEvent.create(it, loggedIn.privKey!!)
|
||||||
|
Client.send(event)
|
||||||
|
LocalCache.consume(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun boost(note: Note) {
|
||||||
|
if (!isWriteable()) return
|
||||||
|
|
||||||
|
note.event?.let {
|
||||||
|
val event = RepostEvent.create(it, loggedIn.privKey!!)
|
||||||
|
Client.send(event)
|
||||||
|
LocalCache.consume(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendPost(message: String, replyingTo: Note?) {
|
||||||
|
if (!isWriteable()) return
|
||||||
|
|
||||||
|
val replyToEvent = replyingTo?.event
|
||||||
|
if (replyToEvent is TextNoteEvent) {
|
||||||
|
val repliesTo = replyToEvent.replyTos.plus(replyToEvent.id.toHex())
|
||||||
|
val mentions = replyToEvent.mentions.plus(replyToEvent.pubKey.toHex())
|
||||||
|
|
||||||
|
val signedEvent = TextNoteEvent.create(
|
||||||
|
msg = message,
|
||||||
|
replyTos = repliesTo,
|
||||||
|
mentions = mentions,
|
||||||
|
privateKey = loggedIn.privKey!!
|
||||||
|
)
|
||||||
|
Client.send(signedEvent)
|
||||||
|
LocalCache.consume(signedEvent)
|
||||||
|
} else {
|
||||||
|
val signedEvent = TextNoteEvent.create(
|
||||||
|
msg = message,
|
||||||
|
replyTos = null,
|
||||||
|
mentions = null,
|
||||||
|
privateKey = loggedIn.privKey!!
|
||||||
|
)
|
||||||
|
Client.send(signedEvent)
|
||||||
|
LocalCache.consume(signedEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observers line up here.
|
||||||
|
val live: AccountLiveData = AccountLiveData(this)
|
||||||
|
|
||||||
|
private fun refreshObservers() {
|
||||||
|
live.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {
|
||||||
|
fun refresh() {
|
||||||
|
postValue(AccountState(account))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActive() {
|
||||||
|
super.onActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInactive() {
|
||||||
|
super.onInactive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountState(val account: Account)
|
36
app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package com.vitorpamplona.amethyst.model
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
|
||||||
|
import fr.acinq.secp256k1.Hex
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
import nostr.postr.Persona
|
||||||
|
import nostr.postr.bechToBytes
|
||||||
|
import nostr.postr.toHex
|
||||||
|
|
||||||
|
/** Makes the distinction between String and Hex **/
|
||||||
|
typealias HexKey = String
|
||||||
|
|
||||||
|
fun ByteArray.toHexKey(): HexKey {
|
||||||
|
return toHex()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun HexKey.toByteArray(): ByteArray {
|
||||||
|
return Hex.decode(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun HexKey.toDisplayHexKey(): String {
|
||||||
|
return this.toDisplayHex()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodePublicKey(key: String): ByteArray {
|
||||||
|
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
|
||||||
|
|
||||||
|
return if (key.startsWith("nsec")) {
|
||||||
|
Persona(privKey = key.bechToBytes()).pubKey
|
||||||
|
} else if (key.startsWith("npub")) {
|
||||||
|
key.bechToBytes()
|
||||||
|
} else { //if (pattern.matcher(key).matches()) {
|
||||||
|
//} else {
|
||||||
|
Hex.decode(key)
|
||||||
|
}
|
||||||
|
}
|
224
app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
package com.vitorpamplona.amethyst.model
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import nostr.postr.events.ContactListEvent
|
||||||
|
import nostr.postr.events.DeletionEvent
|
||||||
|
import nostr.postr.events.MetadataEvent
|
||||||
|
import nostr.postr.events.PrivateDmEvent
|
||||||
|
import nostr.postr.events.RecommendRelayEvent
|
||||||
|
import nostr.postr.events.TextNoteEvent
|
||||||
|
import nostr.postr.toHex
|
||||||
|
|
||||||
|
|
||||||
|
object LocalCache {
|
||||||
|
val metadataParser = jacksonObjectMapper()
|
||||||
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||||
|
.readerFor(UserMetadata::class.java)
|
||||||
|
|
||||||
|
val users = ConcurrentHashMap<HexKey, User>()
|
||||||
|
val notes = ConcurrentHashMap<HexKey, Note>()
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getOrCreateUser(pubkey: ByteArray): User {
|
||||||
|
val key = pubkey.toHexKey()
|
||||||
|
return users[key] ?: run {
|
||||||
|
val answer = User(pubkey)
|
||||||
|
users.put(key, answer)
|
||||||
|
answer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getOrCreateNote(idHex: String): Note {
|
||||||
|
return notes[idHex] ?: run {
|
||||||
|
val answer = Note(idHex)
|
||||||
|
notes.put(idHex, answer)
|
||||||
|
answer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consume(event: MetadataEvent) {
|
||||||
|
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
|
||||||
|
|
||||||
|
// new event
|
||||||
|
val oldUser = getOrCreateUser(event.pubKey)
|
||||||
|
if (event.createdAt > oldUser.updatedMetadataAt) {
|
||||||
|
val newUser = try {
|
||||||
|
metadataParser.readValue<UserMetadata>(ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)), UserMetadata::class.java)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
Log.w("MT", "Content Parse Error ${e.localizedMessage} ${event.content}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oldUser.updateUserInfo(newUser, event.createdAt)
|
||||||
|
} else {
|
||||||
|
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formattedDateTime(timestamp: Long): String {
|
||||||
|
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
|
||||||
|
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consume(event: TextNoteEvent) {
|
||||||
|
val note = getOrCreateNote(event.id.toHex())
|
||||||
|
|
||||||
|
// Already processed this event.
|
||||||
|
if (note.event != null) return
|
||||||
|
|
||||||
|
val author = getOrCreateUser(event.pubKey)
|
||||||
|
val mentions = Collections.synchronizedList(event.mentions.map { getOrCreateUser(decodePublicKey(it)) })
|
||||||
|
val replyTo = Collections.synchronizedList(event.replyTos.map { getOrCreateNote(it) }.toMutableList())
|
||||||
|
|
||||||
|
note.loadEvent(event, author, mentions, replyTo)
|
||||||
|
|
||||||
|
//Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content} ${formattedDateTime(event.createdAt)}")
|
||||||
|
|
||||||
|
// Prepares user's profile view.
|
||||||
|
author.notes.add(note)
|
||||||
|
|
||||||
|
// Adds notifications to users.
|
||||||
|
mentions.forEach {
|
||||||
|
it.taggedPosts.add(note)
|
||||||
|
}
|
||||||
|
replyTo.forEach {
|
||||||
|
it.author?.taggedPosts?.add(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts the replies
|
||||||
|
replyTo.forEach {
|
||||||
|
it.addReply(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consume(event: RecommendRelayEvent) {
|
||||||
|
//Log.d("RR", event.toJson())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consume(event: ContactListEvent) {
|
||||||
|
val user = getOrCreateUser(event.pubKey)
|
||||||
|
//Log.d("CL", "${user.toBestDisplayName()} ${event.follows}")
|
||||||
|
|
||||||
|
if (event.createdAt > user.updatedFollowsAt) {
|
||||||
|
user.updateFollows(
|
||||||
|
event.follows.map {
|
||||||
|
try {
|
||||||
|
val pubKey = decodePublicKey(it.pubKeyHex)
|
||||||
|
getOrCreateUser(pubKey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Could not parse Hex key: ${it.pubKeyHex}")
|
||||||
|
println(event.toJson())
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.filterNotNull(),
|
||||||
|
event.createdAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consume(event: PrivateDmEvent) {
|
||||||
|
//Log.d("PM", event.toJson())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consume(event: DeletionEvent) {
|
||||||
|
//Log.d("DEL", event.toJson())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consume(event: RepostEvent) {
|
||||||
|
val note = getOrCreateNote(event.id.toHex())
|
||||||
|
|
||||||
|
// Already processed this event.
|
||||||
|
if (note.event != null) return
|
||||||
|
|
||||||
|
//Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
|
||||||
|
|
||||||
|
val author = getOrCreateUser(event.pubKey)
|
||||||
|
val mentions = event.originalAuthor.map { getOrCreateUser(decodePublicKey(it)) }.toList()
|
||||||
|
val repliesTo = event.boostedPost.map { getOrCreateNote(it) }.toMutableList()
|
||||||
|
|
||||||
|
note.loadEvent(event, author, mentions, repliesTo)
|
||||||
|
|
||||||
|
// Prepares user's profile view.
|
||||||
|
author.notes.add(note)
|
||||||
|
|
||||||
|
// Adds notifications to users.
|
||||||
|
mentions.forEach {
|
||||||
|
it.taggedPosts.add(note)
|
||||||
|
}
|
||||||
|
repliesTo.forEach {
|
||||||
|
it.author?.taggedPosts?.add(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts the replies
|
||||||
|
repliesTo.forEach {
|
||||||
|
it.addBoost(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consume(event: ReactionEvent) {
|
||||||
|
val note = getOrCreateNote(event.id.toHex())
|
||||||
|
|
||||||
|
// Already processed this event.
|
||||||
|
if (note.event != null) return
|
||||||
|
|
||||||
|
val author = getOrCreateUser(event.pubKey)
|
||||||
|
val mentions = event.originalAuthor.map { getOrCreateUser(decodePublicKey(it)) }
|
||||||
|
val repliesTo = event.originalPost.map { getOrCreateNote(it) }.toMutableList()
|
||||||
|
|
||||||
|
note.loadEvent(event, author, mentions, repliesTo)
|
||||||
|
|
||||||
|
//Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
|
||||||
|
|
||||||
|
// Adds notifications to users.
|
||||||
|
mentions.forEach {
|
||||||
|
it.taggedPosts.add(note)
|
||||||
|
}
|
||||||
|
repliesTo.forEach {
|
||||||
|
it.author?.taggedPosts?.add(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.content == "" || event.content == "+" || event.content == "\uD83E\uDD19") {
|
||||||
|
// Counts the replies
|
||||||
|
repliesTo.forEach {
|
||||||
|
it.addReaction(note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observers line up here.
|
||||||
|
val live: LocalCacheLiveData = LocalCacheLiveData(this)
|
||||||
|
|
||||||
|
private fun refreshObservers() {
|
||||||
|
live.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalCacheLiveData(val cache: LocalCache): LiveData<LocalCacheState>(LocalCacheState(cache)) {
|
||||||
|
fun refresh() {
|
||||||
|
postValue(LocalCacheState(cache))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalCacheState(val cache: LocalCache) {
|
||||||
|
|
||||||
|
}
|
79
app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
package com.vitorpamplona.amethyst.model
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
|
||||||
|
import fr.acinq.secp256k1.Hex
|
||||||
|
import java.util.Collections
|
||||||
|
import nostr.postr.events.Event
|
||||||
|
|
||||||
|
class Note(val idHex: String) {
|
||||||
|
val id = Hex.decode(idHex)
|
||||||
|
val idDisplayHex = id.toDisplayHex()
|
||||||
|
|
||||||
|
var event: Event? = null
|
||||||
|
var author: User? = null
|
||||||
|
var mentions: List<User>? = null
|
||||||
|
var replyTo: MutableList<Note>? = null
|
||||||
|
|
||||||
|
val replies = Collections.synchronizedSet(mutableSetOf<Note>())
|
||||||
|
val reactions = Collections.synchronizedSet(mutableSetOf<Note>())
|
||||||
|
val boosts = Collections.synchronizedSet(mutableSetOf<Note>())
|
||||||
|
|
||||||
|
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: MutableList<Note>) {
|
||||||
|
this.event = event
|
||||||
|
this.author = author
|
||||||
|
this.mentions = mentions
|
||||||
|
this.replyTo = replyTo
|
||||||
|
|
||||||
|
refreshObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addReply(note: Note) {
|
||||||
|
if (replies.add(note))
|
||||||
|
refreshObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addBoost(note: Note) {
|
||||||
|
if (boosts.add(note))
|
||||||
|
refreshObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addReaction(note: Note) {
|
||||||
|
if (reactions.add(note))
|
||||||
|
refreshObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isReactedBy(user: User): Boolean {
|
||||||
|
return reactions.any { it.author == user }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isBoostedBy(user: User): Boolean {
|
||||||
|
return boosts.any { it.author == user }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observers line up here.
|
||||||
|
val live: NoteLiveData = NoteLiveData(this)
|
||||||
|
|
||||||
|
private fun refreshObservers() {
|
||||||
|
live.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
|
||||||
|
fun refresh() {
|
||||||
|
postValue(NoteState(note))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActive() {
|
||||||
|
super.onActive()
|
||||||
|
NostrSingleEventDataSource.add(note.idHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInactive() {
|
||||||
|
super.onInactive()
|
||||||
|
NostrSingleEventDataSource.remove(note.idHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoteState(val note: Note)
|
98
app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package com.vitorpamplona.amethyst.model
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
|
class User(val pubkey: ByteArray) {
|
||||||
|
val pubkeyHex = pubkey.toHexKey()
|
||||||
|
val pubkeyDisplayHex = pubkey.toDisplayHex()
|
||||||
|
|
||||||
|
var info = UserMetadata()
|
||||||
|
|
||||||
|
var updatedMetadataAt: Long = 0;
|
||||||
|
var updatedFollowsAt: Long = 0;
|
||||||
|
|
||||||
|
val notes = Collections.synchronizedSet(mutableSetOf<Note>())
|
||||||
|
val follows = Collections.synchronizedSet(mutableSetOf<User>())
|
||||||
|
val taggedPosts = Collections.synchronizedSet(mutableSetOf<Note>())
|
||||||
|
|
||||||
|
var follower: Number? = null
|
||||||
|
|
||||||
|
fun toBestDisplayName(): String {
|
||||||
|
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bestUsername(): String? {
|
||||||
|
return info.name?.ifBlank { null } ?: info.username?.ifBlank { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bestDisplayName(): String? {
|
||||||
|
return info.displayName?.ifBlank { null } ?: info.display_name?.ifBlank { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun profilePicture(): String {
|
||||||
|
if (info.picture.isNullOrBlank()) info.picture = null
|
||||||
|
return info.picture ?: "https://robohash.org/${pubkeyHex}.png"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateFollows(newFollows: List<User>, updateAt: Long) {
|
||||||
|
follows.clear()
|
||||||
|
follows.addAll(newFollows)
|
||||||
|
updatedFollowsAt = updateAt
|
||||||
|
|
||||||
|
live.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) {
|
||||||
|
info = newUserInfo
|
||||||
|
updatedMetadataAt = updateAt
|
||||||
|
|
||||||
|
live.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observers line up here.
|
||||||
|
val live: UserLiveData = UserLiveData(this)
|
||||||
|
|
||||||
|
private fun refreshObservers() {
|
||||||
|
live.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserMetadata {
|
||||||
|
var name: String? = null
|
||||||
|
var username: String? = null
|
||||||
|
var display_name: String? = null
|
||||||
|
var displayName: String? = null
|
||||||
|
var picture: String? = null
|
||||||
|
var website: String? = null
|
||||||
|
var about: String? = null
|
||||||
|
var nip05: String? = null
|
||||||
|
var domain: String? = null
|
||||||
|
var lud06: String? = null
|
||||||
|
var lud16: String? = null
|
||||||
|
|
||||||
|
var publish: String? = null
|
||||||
|
var iris: String? = null
|
||||||
|
var main_relay: String? = null
|
||||||
|
var twitter: String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
|
||||||
|
fun refresh() {
|
||||||
|
postValue(UserState(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActive() {
|
||||||
|
super.onActive()
|
||||||
|
NostrSingleUserDataSource.add(user.pubkeyHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInactive() {
|
||||||
|
super.onInactive()
|
||||||
|
NostrSingleUserDataSource.remove(user.pubkeyHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserState(val user: User)
|
@ -0,0 +1,10 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
import nostr.postr.JsonFilter
|
||||||
|
|
||||||
|
data class Channel (
|
||||||
|
val id: String = UUID.randomUUID().toString().substring(0,4)
|
||||||
|
) {
|
||||||
|
var filter: JsonFilter? = null // Inactive when null
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||||
|
|
||||||
|
object Constants {
|
||||||
|
val defaultRelays = arrayOf(
|
||||||
|
Relay("wss://nostr.bitcoiner.social", read = true, write = true),
|
||||||
|
Relay("wss://relay.nostr.bg", read = true, write = true),
|
||||||
|
//Relay("wss://brb.io", read = true, write = true),
|
||||||
|
Relay("wss://nostr.v0l.io", read = true, write = true),
|
||||||
|
Relay("wss://nostr.rocks", read = true, write = true),
|
||||||
|
Relay("wss://relay.damus.io", read = true, write = true),
|
||||||
|
Relay("wss://nostr.fmt.wiz.biz", read = true, write = true),
|
||||||
|
Relay("wss://nostr.oxtr.dev", read = true, write = true),
|
||||||
|
Relay("wss://nostr-relay.wlvs.space", read = true, write = true),
|
||||||
|
//Relay("wss://nostr-2.zebedee.cloud", read = true, write = true),
|
||||||
|
Relay("wss://nostr-pub.wellorder.net", read = true, write = true),
|
||||||
|
Relay("wss://nostr.mom", read = true, write = true),
|
||||||
|
Relay("wss://nostr.orangepill.dev", read = true, write = true),
|
||||||
|
//Relay("wss://nostr-pub.semisol.dev", read = true, write = true),
|
||||||
|
Relay("wss://nostr.onsats.org", read = true, write = true),
|
||||||
|
Relay("wss://nostr.sandwich.farm", read = true, write = true)
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,74 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.model.UserState
|
||||||
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
|
import nostr.postr.JsonFilter
|
||||||
|
import nostr.postr.events.TextNoteEvent
|
||||||
|
import nostr.postr.toHex
|
||||||
|
|
||||||
|
object NostrAccountDataSource: NostrDataSource("AccountData") {
|
||||||
|
lateinit var account: Account
|
||||||
|
|
||||||
|
private val cacheListener: (UserState) -> Unit = {
|
||||||
|
resetFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
if (this::account.isInitialized)
|
||||||
|
account.userProfile().live.observeForever(cacheListener)
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
super.stop()
|
||||||
|
if (this::account.isInitialized)
|
||||||
|
account.userProfile().live.removeObserver(cacheListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createAccountFilter(): JsonFilter {
|
||||||
|
return JsonFilter(
|
||||||
|
authors = listOf(account.userProfile().pubkeyHex),
|
||||||
|
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 4), // 4 days
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val accountChannel = requestNewChannel()
|
||||||
|
|
||||||
|
fun <T> equalsIgnoreOrder(list1:List<T>?, list2:List<T>?): Boolean {
|
||||||
|
if (list1 == null && list2 == null) return true
|
||||||
|
if (list1 == null) return false
|
||||||
|
if (list2 == null) return false
|
||||||
|
|
||||||
|
return list1.size == list2.size && list1.toSet() == list2.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun equalAuthors(list1:JsonFilter?, list2:JsonFilter?): Boolean {
|
||||||
|
if (list1 == null && list2 == null) return true
|
||||||
|
if (list1 == null) return false
|
||||||
|
if (list2 == null) return false
|
||||||
|
|
||||||
|
return equalsIgnoreOrder(list1.authors, list2.authors)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun feed(): List<Note> {
|
||||||
|
val user = account.userProfile()
|
||||||
|
val follows = user.follows.map { it.pubkeyHex }.plus(user.pubkeyHex).toSet()
|
||||||
|
|
||||||
|
return LocalCache.notes.values
|
||||||
|
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in follows }
|
||||||
|
.sortedBy { it.event!!.createdAt }
|
||||||
|
.reversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateChannelFilters() {
|
||||||
|
// gets everthing about the user logged in
|
||||||
|
val newAccountFilter = createAccountFilter()
|
||||||
|
|
||||||
|
if (!equalAuthors(newAccountFilter, accountChannel.filter)) {
|
||||||
|
accountChannel.filter = newAccountFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,135 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.relays.Client
|
||||||
|
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||||
|
import java.util.Collections
|
||||||
|
import nostr.postr.events.ContactListEvent
|
||||||
|
import nostr.postr.events.DeletionEvent
|
||||||
|
import nostr.postr.events.Event
|
||||||
|
import nostr.postr.events.MetadataEvent
|
||||||
|
import nostr.postr.events.PrivateDmEvent
|
||||||
|
import nostr.postr.events.RecommendRelayEvent
|
||||||
|
import nostr.postr.events.TextNoteEvent
|
||||||
|
|
||||||
|
abstract class NostrDataSource(val debugName: String) {
|
||||||
|
private val channels = Collections.synchronizedSet(mutableSetOf<Channel>())
|
||||||
|
private val channelIds = Collections.synchronizedSet(mutableSetOf<String>())
|
||||||
|
|
||||||
|
private val clientListener = object : Client.Listener() {
|
||||||
|
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
|
||||||
|
if (subscriptionId in channelIds) {
|
||||||
|
when (event) {
|
||||||
|
is MetadataEvent -> LocalCache.consume(event)
|
||||||
|
is TextNoteEvent -> LocalCache.consume(event)
|
||||||
|
is RecommendRelayEvent -> LocalCache.consume(event)
|
||||||
|
is ContactListEvent -> LocalCache.consume(event)
|
||||||
|
is PrivateDmEvent -> LocalCache.consume(event)
|
||||||
|
is DeletionEvent -> LocalCache.consume(event)
|
||||||
|
is RepostEvent -> LocalCache.consume(event)
|
||||||
|
is ReactionEvent -> LocalCache.consume(event)
|
||||||
|
else -> when (event.kind) {
|
||||||
|
RepostEvent.kind -> LocalCache.consume(RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||||
|
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
|
||||||
|
//Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRelayStateChange(type: Relay.Type, relay: Relay) {
|
||||||
|
//Log.d("RELAY", "Relay ${relay.url} ${when (type) {
|
||||||
|
// Relay.Type.CONNECT -> "connected."
|
||||||
|
// Relay.Type.DISCONNECT -> "disconnected."
|
||||||
|
// Relay.Type.DISCONNECTING -> "disconnecting."
|
||||||
|
// Relay.Type.EOSE -> "sent all events it had stored."
|
||||||
|
//}}")
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (type == Relay.Type.EOSE) {
|
||||||
|
// One everything is loaded, if new users are found, update filters
|
||||||
|
resetFilters()
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
Client.subscribe(clientListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun start() {
|
||||||
|
resetFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun stop() {
|
||||||
|
channels.forEach { channel ->
|
||||||
|
if (channel.filter != null) // if it is active, close
|
||||||
|
Client.close(channel.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadTop(): List<Note> {
|
||||||
|
return feed().take(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestNewChannel(): Channel {
|
||||||
|
val newChannel = Channel()
|
||||||
|
channels.add(newChannel)
|
||||||
|
channelIds.add(newChannel.id)
|
||||||
|
return newChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissChannel(channel: Channel) {
|
||||||
|
Client.close(channel.id)
|
||||||
|
channels.remove(channel)
|
||||||
|
channelIds.remove(channel.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetFilters() {
|
||||||
|
// saves the channels that are currently active
|
||||||
|
val activeChannels = channels.filter { it.filter != null }
|
||||||
|
// saves the current content to only update if it changes
|
||||||
|
val currentFilter = activeChannels.associate { it.id to it.filter!!.toJson() }
|
||||||
|
|
||||||
|
updateChannelFilters()
|
||||||
|
|
||||||
|
// Makes sure to only send an updated filter when it actually changes.
|
||||||
|
channels.forEach { channel ->
|
||||||
|
val channelsNewFilter = channel.filter
|
||||||
|
|
||||||
|
if (channel in activeChannels) {
|
||||||
|
if (channelsNewFilter == null) {
|
||||||
|
// was active and is not active anymore, just close.
|
||||||
|
Client.close(channel.id)
|
||||||
|
} else {
|
||||||
|
// was active and is still active, check if it has changed.
|
||||||
|
if (channelsNewFilter.toJson() != currentFilter[channel.id]) {
|
||||||
|
Client.close(channel.id)
|
||||||
|
Client.sendFilter(channel.id, mutableListOf(channelsNewFilter))
|
||||||
|
} else {
|
||||||
|
// hasn't changed, does nothing.
|
||||||
|
Client.sendFilterOnlyIfDisconnected(channel.id, mutableListOf(channelsNewFilter))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (channelsNewFilter == null) {
|
||||||
|
// was not active and is still not active, does nothing
|
||||||
|
} else {
|
||||||
|
// was not active and becomes active, sends the filter.
|
||||||
|
if (channelsNewFilter.toJson() != currentFilter[channel.id]) {
|
||||||
|
Client.sendFilter(channel.id, mutableListOf(channelsNewFilter))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract fun updateChannelFilters()
|
||||||
|
abstract fun feed(): List<Note>
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
|
import nostr.postr.JsonFilter
|
||||||
|
import nostr.postr.events.TextNoteEvent
|
||||||
|
|
||||||
|
object NostrGlobalDataSource: NostrDataSource("GlobalFeed") {
|
||||||
|
val fifteenMinutes = (60*15) // 15 mins
|
||||||
|
|
||||||
|
fun createGlobalFilter() = JsonFilter(
|
||||||
|
kinds = listOf(TextNoteEvent.kind),
|
||||||
|
since = System.currentTimeMillis() / 1000 - fifteenMinutes
|
||||||
|
)
|
||||||
|
|
||||||
|
val globalFeedChannel = requestNewChannel()
|
||||||
|
|
||||||
|
fun equalTime(list1:Long?, list2:Long?): Boolean {
|
||||||
|
if (list1 == null && list2 == null) return true
|
||||||
|
if (list1 == null) return false
|
||||||
|
if (list2 == null) return false
|
||||||
|
|
||||||
|
return Math.abs(list1 - list2) < (4*fifteenMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun equalFilters(list1:JsonFilter?, list2:JsonFilter?): Boolean {
|
||||||
|
if (list1 == null && list2 == null) return true
|
||||||
|
if (list1 == null) return false
|
||||||
|
if (list2 == null) return false
|
||||||
|
|
||||||
|
return equalTime(list1.since, list2.since)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun feed() = LocalCache.notes.values
|
||||||
|
.filter {
|
||||||
|
it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()
|
||||||
|
}
|
||||||
|
.sortedBy { it.event!!.createdAt }
|
||||||
|
.reversed()
|
||||||
|
|
||||||
|
override fun updateChannelFilters() {
|
||||||
|
val newFilter = createGlobalFilter()
|
||||||
|
|
||||||
|
if (!equalFilters(newFilter, globalFeedChannel.filter)) {
|
||||||
|
globalFeedChannel.filter = newFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,80 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.model.UserState
|
||||||
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
|
import nostr.postr.JsonFilter
|
||||||
|
import nostr.postr.events.TextNoteEvent
|
||||||
|
import nostr.postr.toHex
|
||||||
|
|
||||||
|
object NostrHomeDataSource: NostrDataSource("HomeFeed") {
|
||||||
|
lateinit var account: Account
|
||||||
|
|
||||||
|
private val cacheListener: (UserState) -> Unit = {
|
||||||
|
resetFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
if (this::account.isInitialized)
|
||||||
|
account.userProfile().live.observeForever(cacheListener)
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
super.stop()
|
||||||
|
if (this::account.isInitialized)
|
||||||
|
account.userProfile().live.removeObserver(cacheListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createFollowAccountsFilter(): JsonFilter? {
|
||||||
|
val follows = account.userProfile().follows?.map {
|
||||||
|
it.pubkey.toHex().substring(0, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (follows == null || follows.isEmpty()) return null
|
||||||
|
|
||||||
|
return JsonFilter(
|
||||||
|
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
|
||||||
|
authors = follows,
|
||||||
|
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val followAccountChannel = requestNewChannel()
|
||||||
|
|
||||||
|
fun <T> equalsIgnoreOrder(list1:List<T>?, list2:List<T>?): Boolean {
|
||||||
|
if (list1 == null && list2 == null) return true
|
||||||
|
if (list1 == null) return false
|
||||||
|
if (list2 == null) return false
|
||||||
|
|
||||||
|
return list1.size == list2.size && list1.toSet() == list2.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun equalAuthors(list1:JsonFilter?, list2:JsonFilter?): Boolean {
|
||||||
|
if (list1 == null && list2 == null) return true
|
||||||
|
if (list1 == null) return false
|
||||||
|
if (list2 == null) return false
|
||||||
|
|
||||||
|
return equalsIgnoreOrder(list1.authors, list2.authors)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun feed(): List<Note> {
|
||||||
|
val user = account.userProfile()
|
||||||
|
val follows = user.follows.map { it.pubkeyHex }.plus(user.pubkeyHex).toSet()
|
||||||
|
|
||||||
|
return LocalCache.notes.values
|
||||||
|
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in follows }
|
||||||
|
.sortedBy { it.event!!.createdAt }
|
||||||
|
.reversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateChannelFilters() {
|
||||||
|
val newFollowAccountsFilter = createFollowAccountsFilter()
|
||||||
|
|
||||||
|
if (!equalAuthors(newFollowAccountsFilter, followAccountChannel.filter)) {
|
||||||
|
followAccountChannel.filter = newFollowAccountsFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import nostr.postr.JsonFilter
|
||||||
|
|
||||||
|
object NostrNotificationDataSource: NostrDataSource("GlobalFeed") {
|
||||||
|
lateinit var account: Account
|
||||||
|
|
||||||
|
fun createGlobalFilter() = JsonFilter(
|
||||||
|
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 2 days
|
||||||
|
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex).filterNotNull())
|
||||||
|
)
|
||||||
|
|
||||||
|
val notificationChannel = requestNewChannel()
|
||||||
|
|
||||||
|
fun <T> equalsIgnoreOrder(list1:List<T>?, list2:List<T>?): Boolean {
|
||||||
|
if (list1 == null && list2 == null) return true
|
||||||
|
if (list1 == null) return false
|
||||||
|
if (list2 == null) return false
|
||||||
|
|
||||||
|
return list1.size == list2.size && list1.toSet() == list2.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun equalFilters(list1:JsonFilter?, list2:JsonFilter?): Boolean {
|
||||||
|
if (list1 == null && list2 == null) return true
|
||||||
|
if (list1 == null) return false
|
||||||
|
if (list2 == null) return false
|
||||||
|
|
||||||
|
return equalsIgnoreOrder(list1.tags?.get("p"), list2.tags?.get("p"))
|
||||||
|
&& equalsIgnoreOrder(list1.tags?.get("e"), list2.tags?.get("e"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun feed(): List<Note> {
|
||||||
|
return account.userProfile().taggedPosts
|
||||||
|
.filter { it.event != null }
|
||||||
|
.sortedBy { it.event!!.createdAt }
|
||||||
|
.reversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateChannelFilters() {
|
||||||
|
val newFilter = createGlobalFilter()
|
||||||
|
|
||||||
|
if (!equalFilters(newFilter, notificationChannel.filter)) {
|
||||||
|
notificationChannel.filter = newFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import java.util.Collections
|
||||||
|
import nostr.postr.JsonFilter
|
||||||
|
|
||||||
|
object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
|
||||||
|
val eventsToWatch = Collections.synchronizedList(mutableListOf<String>())
|
||||||
|
|
||||||
|
fun createRepliesAndReactionsFilter(): JsonFilter? {
|
||||||
|
val reactionsToWatch = eventsToWatch.map { it.substring(0, 8) }
|
||||||
|
|
||||||
|
if (reactionsToWatch.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonFilter(
|
||||||
|
tags = mapOf("e" to reactionsToWatch)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
|
||||||
|
val eventsToLoad = eventsToWatch
|
||||||
|
.map { LocalCache.notes[it] }
|
||||||
|
.filterNotNull()
|
||||||
|
.filter { it.event == null }
|
||||||
|
.map { it.idHex.substring(0, 8) }
|
||||||
|
|
||||||
|
if (eventsToLoad.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonFilter(
|
||||||
|
ids = eventsToLoad
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val repliesAndReactionsChannel = requestNewChannel()
|
||||||
|
val loadEventsChannel = requestNewChannel()
|
||||||
|
|
||||||
|
override fun feed(): List<Note> {
|
||||||
|
return eventsToWatch.map {
|
||||||
|
LocalCache.notes[it]
|
||||||
|
}.filterNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateChannelFilters() {
|
||||||
|
repliesAndReactionsChannel.filter = createRepliesAndReactionsFilter()
|
||||||
|
loadEventsChannel.filter = createLoadEventsIfNotLoadedFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun add(eventId: String) {
|
||||||
|
eventsToWatch.add(eventId)
|
||||||
|
resetFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(eventId: String) {
|
||||||
|
eventsToWatch.remove(eventId)
|
||||||
|
resetFilters()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,42 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import java.util.Collections
|
||||||
|
import nostr.postr.JsonFilter
|
||||||
|
import nostr.postr.events.MetadataEvent
|
||||||
|
|
||||||
|
object NostrSingleUserDataSource: NostrDataSource("SingleUserFeed") {
|
||||||
|
val usersToWatch = Collections.synchronizedList(mutableListOf<String>())
|
||||||
|
|
||||||
|
fun createUserFilter(): JsonFilter? {
|
||||||
|
if (usersToWatch.isEmpty()) return null
|
||||||
|
|
||||||
|
return JsonFilter(
|
||||||
|
kinds = listOf(MetadataEvent.kind),
|
||||||
|
authors = usersToWatch.map { it.substring(0, 8) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val userChannel = requestNewChannel()
|
||||||
|
|
||||||
|
override fun feed(): List<Note> {
|
||||||
|
return usersToWatch.map {
|
||||||
|
LocalCache.notes[it]
|
||||||
|
}.filterNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateChannelFilters() {
|
||||||
|
userChannel.filter = createUserFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun add(userId: String) {
|
||||||
|
usersToWatch.add(userId)
|
||||||
|
resetFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(userId: String) {
|
||||||
|
usersToWatch.remove(userId)
|
||||||
|
resetFilters()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,37 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service.model
|
||||||
|
|
||||||
|
import java.util.Date
|
||||||
|
import nostr.postr.Utils
|
||||||
|
import nostr.postr.events.Event
|
||||||
|
import nostr.postr.toHex
|
||||||
|
|
||||||
|
class ReactionEvent (
|
||||||
|
id: ByteArray,
|
||||||
|
pubKey: ByteArray,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: List<List<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: ByteArray
|
||||||
|
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||||
|
|
||||||
|
@Transient val originalPost: List<String>
|
||||||
|
@Transient val originalAuthor: List<String>
|
||||||
|
|
||||||
|
init {
|
||||||
|
originalPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||||
|
originalAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val kind = 7
|
||||||
|
|
||||||
|
fun create(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||||
|
val content = "+"
|
||||||
|
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||||
|
val tags = listOf( listOf("e", originalNote.id.toHex()), listOf("p", originalNote.pubKey.toHex()))
|
||||||
|
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||||
|
val sig = Utils.sign(id, privateKey)
|
||||||
|
return ReactionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service.model
|
||||||
|
|
||||||
|
import java.util.Date
|
||||||
|
import nostr.postr.Utils
|
||||||
|
import nostr.postr.events.Event
|
||||||
|
import nostr.postr.toHex
|
||||||
|
|
||||||
|
class RepostEvent (
|
||||||
|
id: ByteArray,
|
||||||
|
pubKey: ByteArray,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: List<List<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: ByteArray
|
||||||
|
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||||
|
|
||||||
|
@Transient val boostedPost: List<String>
|
||||||
|
@Transient val originalAuthor: List<String>
|
||||||
|
|
||||||
|
init {
|
||||||
|
boostedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||||
|
originalAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val kind = 6
|
||||||
|
|
||||||
|
fun create(boostedPost: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RepostEvent {
|
||||||
|
val content = ""
|
||||||
|
|
||||||
|
val replyToPost = listOf("e", boostedPost.id.toHex())
|
||||||
|
val replyToAuthor = listOf("p", boostedPost.pubKey.toHex())
|
||||||
|
|
||||||
|
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||||
|
val tags:List<List<String>> = boostedPost.tags.plus(listOf(replyToPost, replyToAuthor))
|
||||||
|
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||||
|
val sig = Utils.sign(id, privateKey)
|
||||||
|
return RepostEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,113 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service.relays
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.service.Constants
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import nostr.postr.JsonFilter
|
||||||
|
import nostr.postr.events.Event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Nostr Client manages multiple personae the user may switch between. Events are received and
|
||||||
|
* published through multiple relays.
|
||||||
|
* Events are stored with their respective persona.
|
||||||
|
*/
|
||||||
|
object Client: RelayPool.Listener {
|
||||||
|
/**
|
||||||
|
* Lenient mode:
|
||||||
|
*
|
||||||
|
* true: For maximum compatibility. If you want to play ball with sloppy counterparts, use
|
||||||
|
* this.
|
||||||
|
* false: For developers who want to make protocol compliant counterparts. If your software
|
||||||
|
* produces events that fail to deserialize in strict mode, you should probably fix
|
||||||
|
* something.
|
||||||
|
**/
|
||||||
|
var lenient: Boolean = false
|
||||||
|
private val listeners = Collections.synchronizedSet(HashSet<Listener>())
|
||||||
|
internal var relays = Constants.defaultRelays
|
||||||
|
internal val subscriptions = ConcurrentHashMap<String, MutableList<JsonFilter>>()
|
||||||
|
|
||||||
|
fun connect(
|
||||||
|
relays: Array<Relay> = Constants.defaultRelays
|
||||||
|
) {
|
||||||
|
RelayPool.register(this)
|
||||||
|
RelayPool.loadRelays(relays.toList())
|
||||||
|
this.relays = relays
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestAndWatch(
|
||||||
|
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
|
||||||
|
filters: MutableList<JsonFilter> = mutableListOf(JsonFilter())
|
||||||
|
) {
|
||||||
|
subscriptions[subscriptionId] = filters
|
||||||
|
RelayPool.requestAndWatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendFilter(
|
||||||
|
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
|
||||||
|
filters: MutableList<JsonFilter> = mutableListOf(JsonFilter())
|
||||||
|
) {
|
||||||
|
subscriptions[subscriptionId] = filters
|
||||||
|
RelayPool.sendFilter(subscriptionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendFilterOnlyIfDisconnected(
|
||||||
|
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
|
||||||
|
filters: MutableList<JsonFilter> = mutableListOf(JsonFilter())
|
||||||
|
) {
|
||||||
|
subscriptions[subscriptionId] = filters
|
||||||
|
RelayPool.sendFilterOnlyIfDisconnected(subscriptionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun send(signedEvent: Event) {
|
||||||
|
RelayPool.send(signedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close(subscriptionId: String){
|
||||||
|
RelayPool.close(subscriptionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
RelayPool.unregister(this)
|
||||||
|
RelayPool.disconnect()
|
||||||
|
RelayPool.unloadRelays()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
|
||||||
|
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
|
||||||
|
listeners.forEach { it.onError(error, subscriptionId, relay) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRelayStateChange(type: Relay.Type, relay: Relay) {
|
||||||
|
listeners.forEach { it.onRelayStateChange(type, relay) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribe(listener: Listener) {
|
||||||
|
listeners.add(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unsubscribe(listener: Listener): Boolean {
|
||||||
|
return listeners.remove(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
abstract class Listener {
|
||||||
|
/**
|
||||||
|
* A new message was received
|
||||||
|
*/
|
||||||
|
open fun onEvent(event: Event, subscriptionId: String, relay: Relay) = Unit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new or repeat message was received
|
||||||
|
*/
|
||||||
|
open fun onError(error: Error, subscriptionId: String, relay: Relay) = Unit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connected to or disconnected from a relay
|
||||||
|
*/
|
||||||
|
open fun onRelayStateChange(type: Relay.Type, relay: Relay) = Unit
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,165 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service.relays
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement
|
||||||
|
import java.util.Collections
|
||||||
|
import nostr.postr.JsonFilter
|
||||||
|
import nostr.postr.events.Event
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
|
||||||
|
class Relay(
|
||||||
|
val url: String,
|
||||||
|
var read: Boolean = true,
|
||||||
|
var write: Boolean = true
|
||||||
|
) {
|
||||||
|
private val httpClient = OkHttpClient()
|
||||||
|
private val listeners = Collections.synchronizedSet(HashSet<Listener>())
|
||||||
|
private var socket: WebSocket? = null
|
||||||
|
|
||||||
|
fun register(listener: Listener) {
|
||||||
|
listeners.add(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isConnected(): Boolean {
|
||||||
|
return socket != null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregister(listener: Listener) = listeners.remove(listener)
|
||||||
|
|
||||||
|
fun requestAndWatch(reconnectTs: Long? = null) {
|
||||||
|
val request = Request.Builder().url(url).build()
|
||||||
|
val listener = object : WebSocketListener() {
|
||||||
|
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
|
// Sends everything.
|
||||||
|
Client.subscriptions.forEach {
|
||||||
|
sendFilter(requestId = it.key, reconnectTs = reconnectTs)
|
||||||
|
}
|
||||||
|
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
try {
|
||||||
|
val msg = Event.gson.fromJson(text, JsonElement::class.java).asJsonArray
|
||||||
|
val type = msg[0].asString
|
||||||
|
val channel = msg[1].asString
|
||||||
|
when (type) {
|
||||||
|
"EVENT" -> {
|
||||||
|
val event = Event.fromJson(msg[2], Client.lenient)
|
||||||
|
listeners.forEach { it.onEvent(this@Relay, channel, event) }
|
||||||
|
}
|
||||||
|
"EOSE" -> listeners.forEach {
|
||||||
|
it.onRelayStateChange(this@Relay, Type.EOSE)
|
||||||
|
}
|
||||||
|
"NOTICE" -> listeners.forEach {
|
||||||
|
// "channel" being the second string in the string array ...
|
||||||
|
it.onError(this@Relay, channel, Error("Relay sent notice: $channel"))
|
||||||
|
}
|
||||||
|
"OK" -> listeners.forEach {
|
||||||
|
// "channel" being the second string in the string array ...
|
||||||
|
// Event was saved correctly?
|
||||||
|
}
|
||||||
|
else -> listeners.forEach {
|
||||||
|
it.onError(
|
||||||
|
this@Relay,
|
||||||
|
channel,
|
||||||
|
Error("Unknown type $type on channel $channel. Msg was $text")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
t.printStackTrace()
|
||||||
|
text.chunked(2000) { chunked ->
|
||||||
|
listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECTING) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
socket = null
|
||||||
|
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
t.printStackTrace()
|
||||||
|
listeners.forEach {
|
||||||
|
it.onError(this@Relay, "", Error("WebSocket Failure. Response: ${response}. Exception: ${t.message}", t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket = httpClient.newWebSocket(request, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
//httpClient.dispatcher.executorService.shutdown()
|
||||||
|
socket?.close(1000, "Normal close")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendFilter(requestId: String, reconnectTs: Long? = null) {
|
||||||
|
if (socket == null) {
|
||||||
|
requestAndWatch(reconnectTs)
|
||||||
|
} else {
|
||||||
|
val filters = if (reconnectTs != null) {
|
||||||
|
Client.subscriptions[requestId]?.let {
|
||||||
|
it.map { filter ->
|
||||||
|
JsonFilter(filter.ids, filter.authors, filter.kinds, filter.tags, since = reconnectTs)
|
||||||
|
}
|
||||||
|
} ?: error("No filter(s) found.")
|
||||||
|
} else {
|
||||||
|
Client.subscriptions[requestId] ?: error("No filter(s) found.")
|
||||||
|
}
|
||||||
|
val request = """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]"""
|
||||||
|
//println("FILTERSSENT " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""")
|
||||||
|
socket!!.send(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendFilterOnlyIfDisconnected(requestId: String, reconnectTs: Long? = null) {
|
||||||
|
if (socket == null) {
|
||||||
|
requestAndWatch(reconnectTs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun send(signedEvent: Event) {
|
||||||
|
if (write)
|
||||||
|
socket?.send("""["EVENT",${signedEvent.toJson()}]""")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close(subscriptionId: String){
|
||||||
|
socket?.send("""["CLOSE","$subscriptionId"]""")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Type {
|
||||||
|
// Websocket connected
|
||||||
|
CONNECT,
|
||||||
|
// Websocket disconnecting
|
||||||
|
DISCONNECTING,
|
||||||
|
// Websocket disconnected
|
||||||
|
DISCONNECT,
|
||||||
|
// End Of Stored Events
|
||||||
|
EOSE
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
/**
|
||||||
|
* A new message was received
|
||||||
|
*/
|
||||||
|
fun onEvent(relay: Relay, subscriptionId: String, event: Event)
|
||||||
|
|
||||||
|
fun onError(relay: Relay, subscriptionId: String, error: Error)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connected to or disconnected from a relay
|
||||||
|
*
|
||||||
|
* @param type is 0 for disconnect and 1 for connect
|
||||||
|
*/
|
||||||
|
fun onRelayStateChange(relay: Relay, type: Type)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,113 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service.relays
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import com.vitorpamplona.amethyst.service.Constants
|
||||||
|
import java.util.Collections
|
||||||
|
import nostr.postr.events.Event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
|
||||||
|
*/
|
||||||
|
object RelayPool: Relay.Listener {
|
||||||
|
private val relays = Collections.synchronizedList(ArrayList<Relay>())
|
||||||
|
private val listeners = Collections.synchronizedSet(HashSet<Listener>())
|
||||||
|
|
||||||
|
fun report(): String {
|
||||||
|
val connected = relays.filter { it.isConnected() }
|
||||||
|
return "${connected.size}/${relays.size}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadRelays(relayList: List<Relay>? = null){
|
||||||
|
if (!relayList.isNullOrEmpty()){
|
||||||
|
relayList.forEach { addRelay(it) }
|
||||||
|
} else {
|
||||||
|
Constants.defaultRelays.forEach { addRelay(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unloadRelays() {
|
||||||
|
relays.toList().forEach { removeRelay(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestAndWatch() {
|
||||||
|
relays.forEach { it.requestAndWatch() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendFilter(subscriptionId: String) {
|
||||||
|
relays.forEach { it.sendFilter(subscriptionId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendFilterOnlyIfDisconnected(subscriptionId: String) {
|
||||||
|
relays.forEach { it.sendFilterOnlyIfDisconnected(subscriptionId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun send(signedEvent: Event) {
|
||||||
|
relays.forEach { it.send(signedEvent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close(subscriptionId: String){
|
||||||
|
relays.forEach { it.close(subscriptionId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
relays.forEach { it.disconnect() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addRelay(relay: Relay) {
|
||||||
|
relay.register(this)
|
||||||
|
relays += relay
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeRelay(relay: Relay): Boolean {
|
||||||
|
relay.unregister(this)
|
||||||
|
return relays.remove(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRelays(): List<Relay> = relays
|
||||||
|
|
||||||
|
fun register(listener: Listener) {
|
||||||
|
listeners.add(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregister(listener: Listener): Boolean {
|
||||||
|
return listeners.remove(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
fun onEvent(event: Event, subscriptionId: String, relay: Relay)
|
||||||
|
|
||||||
|
fun onError(error: Error, subscriptionId: String, relay: Relay)
|
||||||
|
|
||||||
|
fun onRelayStateChange(type: Relay.Type, relay: Relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
|
||||||
|
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(relay: Relay, subscriptionId: String, error: Error) {
|
||||||
|
listeners.forEach { it.onError(error, subscriptionId, relay) }
|
||||||
|
refreshObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRelayStateChange(relay: Relay, type: Relay.Type) {
|
||||||
|
listeners.forEach { it.onRelayStateChange(type, relay) }
|
||||||
|
refreshObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observers line up here.
|
||||||
|
val live: RelayPoolLiveData = RelayPoolLiveData(this)
|
||||||
|
|
||||||
|
private fun refreshObservers() {
|
||||||
|
live.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RelayPoolLiveData(val relays: RelayPool): LiveData<RelayPoolState>(RelayPoolState(relays)) {
|
||||||
|
fun refresh() {
|
||||||
|
postValue(RelayPoolState(relays))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RelayPoolState(val relays: RelayPool)
|
@ -0,0 +1,60 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.vitorpamplona.amethyst.KeyStorage
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.relays.Client
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
AmethystTheme {
|
||||||
|
// A surface container using the 'background' color from the theme
|
||||||
|
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||||
|
|
||||||
|
val accountViewModel: AccountStateViewModel = viewModel {
|
||||||
|
AccountStateViewModel(KeyStorage().encryptedPreferences(applicationContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountScreen(accountViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Client.lenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
Client.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
NostrAccountDataSource.stop()
|
||||||
|
NostrHomeDataSource.stop()
|
||||||
|
|
||||||
|
NostrGlobalDataSource.stop()
|
||||||
|
NostrNotificationDataSource.stop()
|
||||||
|
NostrSingleEventDataSource.stop()
|
||||||
|
NostrSingleUserDataSource.stop()
|
||||||
|
Client.disconnect()
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,157 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.actions
|
||||||
|
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.Button
|
||||||
|
import androidx.compose.material.ButtonDefaults
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.OutlinedTextField
|
||||||
|
import androidx.compose.material.Surface
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
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 androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import nostr.postr.events.TextNoteEvent
|
||||||
|
|
||||||
|
class PostViewModel: ViewModel() {
|
||||||
|
var account: Account? = null
|
||||||
|
var message by mutableStateOf("")
|
||||||
|
var replyingTo: Note? = null
|
||||||
|
|
||||||
|
fun sendPost() {
|
||||||
|
account?.sendPost(message, replyingTo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account) {
|
||||||
|
val postViewModel: PostViewModel = viewModel<PostViewModel>().apply {
|
||||||
|
this.replyingTo = replyingTo
|
||||||
|
this.account = account
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialogProperties = DialogProperties()
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = { onClose() }, properties = dialogProperties
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(0.5f)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(10.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
CloseButton(onCancel = onClose)
|
||||||
|
|
||||||
|
PostButton(
|
||||||
|
onPost = {
|
||||||
|
postViewModel.sendPost()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replyingTo != null && replyingTo.event is TextNoteEvent) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
val replyList = replyingTo.replyTo!!.plus(replyingTo).joinToString(", ", "", "", 2) { it.idDisplayHex }
|
||||||
|
val withList = replyingTo.mentions!!.plus(replyingTo.author!!).joinToString(", ", "", "", 2) { it.toBestDisplayName() }
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"in reply to ${replyList} with ${withList}",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = postViewModel.message,
|
||||||
|
onValueChange = { postViewModel.message = it },
|
||||||
|
keyboardOptions = KeyboardOptions.Default.copy(
|
||||||
|
capitalization = KeyboardCapitalization.Sentences
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth().fillMaxHeight()
|
||||||
|
.border(
|
||||||
|
width = 1.dp,
|
||||||
|
color = MaterialTheme.colors.surface,
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
),
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "What's on your mind?",
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = TextFieldDefaults
|
||||||
|
.outlinedTextFieldColors(
|
||||||
|
unfocusedBorderColor = Color.Transparent,
|
||||||
|
focusedBorderColor = Color.Transparent
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CloseButton(onCancel: () -> Unit) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onCancel()
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = ButtonDefaults
|
||||||
|
.buttonColors(
|
||||||
|
backgroundColor = Color.Gray
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(text = "Cancel", color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PostButton(onPost: () -> Unit = {}) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onPost()
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = ButtonDefaults
|
||||||
|
.buttonColors(
|
||||||
|
backgroundColor = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(text = "Post", color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,46 @@
|
|||||||
|
package com.vitorpamplona.amethyst.buttons
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.ButtonDefaults
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.OutlinedButton
|
||||||
|
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.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NewNoteButton(account: Account) {
|
||||||
|
var wantsToPost by remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantsToPost)
|
||||||
|
NewPostView({ wantsToPost = false }, account = account)
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { wantsToPost = true },
|
||||||
|
modifier = Modifier.size(55.dp),
|
||||||
|
shape = CircleShape,
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_compose),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(26.dp),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,115 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.google.accompanist.flowlayout.FlowRow
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
|
import java.net.MalformedURLException
|
||||||
|
import java.net.URISyntaxException
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
|
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp)$")
|
||||||
|
val noProtocolUrlValidator = Pattern.compile("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$")
|
||||||
|
val tagIndex = Pattern.compile("\\#\\[([0-9]*)\\]")
|
||||||
|
|
||||||
|
fun isValidURL(url: String?): Boolean {
|
||||||
|
return try {
|
||||||
|
URL(url).toURI()
|
||||||
|
true
|
||||||
|
} catch (e: MalformedURLException) {
|
||||||
|
false
|
||||||
|
} catch (e: URISyntaxException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RichTextViewer(content: String, tags: List<List<String>>?) {
|
||||||
|
Column(modifier = Modifier.padding(top = 5.dp)) {
|
||||||
|
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||||
|
content.split('\n').forEach { paragraph ->
|
||||||
|
|
||||||
|
FlowRow() {
|
||||||
|
paragraph.split(' ').forEach { word: String ->
|
||||||
|
// Explicit URL
|
||||||
|
if (isValidURL(word)) {
|
||||||
|
val removedParamsFromUrl = word.split("?")[0].toLowerCase()
|
||||||
|
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = word,
|
||||||
|
contentDescription = word,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 4.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape = RoundedCornerShape(15.dp))
|
||||||
|
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
UrlPreview(word, word)
|
||||||
|
}
|
||||||
|
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||||
|
UrlPreview("https://$word", word)
|
||||||
|
} else if (tagIndex.matcher(word).matches() && tags != null) {
|
||||||
|
TagLink(word, tags)
|
||||||
|
} else {
|
||||||
|
Text(text = "$word ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TagLink(word: String, tags: List<List<String>>) {
|
||||||
|
val matcher = tagIndex.matcher(word)
|
||||||
|
|
||||||
|
val index = try {
|
||||||
|
matcher.find()
|
||||||
|
matcher.group(1).toInt()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Couldn't link tag ${word}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index == null) {
|
||||||
|
return Text(text = "$word ")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index > 0 && index < tags.size) {
|
||||||
|
if (tags[index][0] == "p") {
|
||||||
|
val user = LocalCache.users[tags[index][1]]
|
||||||
|
if (user != null) {
|
||||||
|
val innerUserState by user.live.observeAsState()
|
||||||
|
Text(
|
||||||
|
"${innerUserState?.user?.toBestDisplayName()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (tags[index][0] == "e") {
|
||||||
|
val note = LocalCache.notes[tags[index][1]]
|
||||||
|
if (note != null) {
|
||||||
|
val innerNoteState by note.live.observeAsState()
|
||||||
|
Text(
|
||||||
|
"${innerNoteState?.note?.idDisplayHex}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
Text(text = "$word ")
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,109 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
|
import androidx.compose.material.LocalTextStyle
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
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.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.baha.url.preview.BahaUrlPreview
|
||||||
|
import com.baha.url.preview.IUrlPreviewCallback
|
||||||
|
import com.baha.url.preview.UrlInfoItem
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UrlPreview(url: String, urlText: String) {
|
||||||
|
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(UrlPreviewState.Loading) }
|
||||||
|
|
||||||
|
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
|
||||||
|
LaunchedEffect(urlPreviewState) {
|
||||||
|
if (urlPreviewState == UrlPreviewState.Loading) {
|
||||||
|
val urlPreview = BahaUrlPreview(url, object : IUrlPreviewCallback {
|
||||||
|
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||||
|
if (urlInfo.allFetchComplete() && urlInfo.url == url)
|
||||||
|
urlPreviewState = UrlPreviewState.Loaded(urlInfo)
|
||||||
|
else
|
||||||
|
urlPreviewState = UrlPreviewState.Empty
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailed(throwable: Throwable) {
|
||||||
|
urlPreviewState = UrlPreviewState.Error("Error parsing preview for ${url}: ${throwable.message}")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
urlPreview.fetchUrlPreview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = LocalUriHandler.current
|
||||||
|
|
||||||
|
Crossfade(targetState = urlPreviewState) { state ->
|
||||||
|
when (state) {
|
||||||
|
is UrlPreviewState.Loaded -> {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.clickable { runCatching { uri.openUri(url) } }
|
||||||
|
.clip(shape = RoundedCornerShape(15.dp))
|
||||||
|
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
AsyncImage(
|
||||||
|
model = state.previewInfo.image,
|
||||||
|
contentDescription = "Profile Image",
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = state.previewInfo.title,
|
||||||
|
style = MaterialTheme.typography.body2,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top= 10.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = state.previewInfo.description,
|
||||||
|
style = MaterialTheme.typography.caption,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
|
||||||
|
color = Color.Gray,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
ClickableText(
|
||||||
|
text = AnnotatedString("$urlText "),
|
||||||
|
onClick = { runCatching { uri.openUri(url) } },
|
||||||
|
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.components
|
||||||
|
|
||||||
|
import com.baha.url.preview.UrlInfoItem
|
||||||
|
|
||||||
|
sealed class UrlPreviewState {
|
||||||
|
object Loading: UrlPreviewState()
|
||||||
|
class Loaded(val previewInfo: UrlInfoItem): UrlPreviewState()
|
||||||
|
object Empty: UrlPreviewState()
|
||||||
|
class Error(val errorMessage: String): UrlPreviewState()
|
||||||
|
}
|
@ -0,0 +1,60 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.BottomNavigation
|
||||||
|
import androidx.compose.material.BottomNavigationItem
|
||||||
|
import androidx.compose.material.Divider
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
|
||||||
|
val bottomNavigationItems = listOf(
|
||||||
|
Route.Home,
|
||||||
|
Route.Message,
|
||||||
|
Route.Search,
|
||||||
|
Route.Notification
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppBottomBar(navController: NavHostController) {
|
||||||
|
val currentRoute = currentRoute(navController)
|
||||||
|
|
||||||
|
Column() {
|
||||||
|
Divider(
|
||||||
|
thickness = 0.25.dp
|
||||||
|
)
|
||||||
|
BottomNavigation(
|
||||||
|
modifier = Modifier,
|
||||||
|
elevation = 0.dp,
|
||||||
|
backgroundColor = MaterialTheme.colors.background
|
||||||
|
) {
|
||||||
|
bottomNavigationItems.forEach { item ->
|
||||||
|
BottomNavigationItem(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = item.icon),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(if ("Home" == item.route) 24.dp else 20.dp),
|
||||||
|
tint = if (currentRoute == item.route) MaterialTheme.colors.primary else Color.Unspecified
|
||||||
|
)
|
||||||
|
},
|
||||||
|
selected = currentRoute == item.route,
|
||||||
|
onClick = {
|
||||||
|
if (currentRoute != item.route) {
|
||||||
|
navController.navigate(item.route)
|
||||||
|
} else {
|
||||||
|
// TODO: Make it scrool to the top
|
||||||
|
navController.navigate(item.route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppNavigation(
|
||||||
|
navController: NavHostController,
|
||||||
|
accountViewModel: AccountViewModel
|
||||||
|
) {
|
||||||
|
NavHost(navController, startDestination = Route.Home.route) {
|
||||||
|
Routes.forEach {
|
||||||
|
composable(it.route, content = it.buildScreen(accountViewModel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,154 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.navigation
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.Divider
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.IconButton
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.ScaffoldState
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.TopAppBar
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
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.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.service.relays.Client
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||||
|
when (currentRoute(navController)) {
|
||||||
|
Route.Profile.route,
|
||||||
|
Route.Lists.route,
|
||||||
|
Route.Topics.route,
|
||||||
|
Route.Bookmarks.route,
|
||||||
|
Route.Moments.route -> TopBarWithBackButton(navController)
|
||||||
|
else -> MainTopBar(scaffoldState, accountViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||||
|
val accountUserState by accountViewModel.userLiveData.observeAsState()
|
||||||
|
val accountUser = accountUserState?.user
|
||||||
|
|
||||||
|
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
|
||||||
|
val relayPoolLiveData by relayViewModel.relayPoolLiveData.observeAsState()
|
||||||
|
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Column() {
|
||||||
|
TopAppBar(
|
||||||
|
elevation = 0.dp,
|
||||||
|
backgroundColor = Color(0xFFFFFF),
|
||||||
|
title = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 22.dp, end = 0.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
Client.subscriptions.map { "${it.key} ${it.value.joinToString { it.toJson() }}" }.forEach {
|
||||||
|
Log.d("CURRENT FILTERS", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_amethyst),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
tint = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
scaffoldState.drawerState.open()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png",
|
||||||
|
contentDescription = "Profile Image",
|
||||||
|
modifier = Modifier
|
||||||
|
.width(34.dp)
|
||||||
|
.clip(shape = CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
Text(
|
||||||
|
relayPoolLiveData ?: "--/--",
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = {}, modifier = Modifier
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_trends),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = Color.Unspecified
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Divider(thickness = 0.25.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TopBarWithBackButton(navController: NavHostController) {
|
||||||
|
Column() {
|
||||||
|
TopAppBar(
|
||||||
|
elevation = 0.dp,
|
||||||
|
backgroundColor = Color(0xFFFFFF),
|
||||||
|
title = {},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ArrowBack,
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
tint = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {}
|
||||||
|
)
|
||||||
|
Divider(thickness = 0.25.dp)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,201 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.Divider
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.IconButton
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.ScaffoldState
|
||||||
|
import androidx.compose.material.Surface
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
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.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.font.FontWeight.Companion.W500
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.User
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
val bottomNavigations = listOf(
|
||||||
|
Route.Profile,
|
||||||
|
Route.Lists,
|
||||||
|
//Route.Topics,
|
||||||
|
Route.Bookmarks,
|
||||||
|
//Route.Moments
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DrawerContent(navController: NavHostController,
|
||||||
|
scaffoldState: ScaffoldState,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
accountStateViewModel: AccountStateViewModel) {
|
||||||
|
|
||||||
|
val accountUserState by accountViewModel.userLiveData.observeAsState()
|
||||||
|
val accountUser = accountUserState?.user
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colors.background
|
||||||
|
) {
|
||||||
|
Column() {
|
||||||
|
Box {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.profile_banner),
|
||||||
|
contentDescription = "Profile Banner",
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
ProfileContent(
|
||||||
|
accountUser,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 25.dp)
|
||||||
|
.padding(top = 125.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Divider(
|
||||||
|
thickness = 0.25.dp,
|
||||||
|
modifier = Modifier.padding(top = 20.dp)
|
||||||
|
)
|
||||||
|
ListContent(
|
||||||
|
navController,
|
||||||
|
scaffoldState,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1F),
|
||||||
|
accountStateViewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
AsyncImage(
|
||||||
|
model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png",
|
||||||
|
contentDescription = "Profile Image",
|
||||||
|
modifier = Modifier
|
||||||
|
.width(100.dp)
|
||||||
|
.clip(shape = CircleShape)
|
||||||
|
.border(3.dp, MaterialTheme.colors.background, CircleShape)
|
||||||
|
.background(MaterialTheme.colors.background)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
accountUser?.bestDisplayName() ?: "",
|
||||||
|
modifier = Modifier.padding(top = 7.dp),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
Text(" @${accountUser?.bestUsername()}", color = Color.LightGray)
|
||||||
|
Row(modifier = Modifier.padding(top = 15.dp)) {
|
||||||
|
Row() {
|
||||||
|
Text("${accountUser?.follows?.size}", fontWeight = FontWeight.Bold)
|
||||||
|
Text(" Following")
|
||||||
|
}
|
||||||
|
Row(modifier = Modifier.padding(start = 10.dp)) {
|
||||||
|
Text("${accountUser?.follower ?: "--"}", fontWeight = FontWeight.Bold)
|
||||||
|
Text(" Followers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ListContent(
|
||||||
|
navController: NavHostController,
|
||||||
|
scaffoldState: ScaffoldState,
|
||||||
|
modifier: Modifier,
|
||||||
|
accountViewModel: AccountStateViewModel
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
LazyColumn() {
|
||||||
|
items(items = bottomNavigations) {
|
||||||
|
NavigationRow(navController, scaffoldState, it)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Divider(
|
||||||
|
modifier = Modifier.padding(vertical = 15.dp),
|
||||||
|
thickness = 0.25.dp
|
||||||
|
)
|
||||||
|
Column(modifier = modifier.padding(horizontal = 25.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Settings",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = W500
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.clickable(onClick = { accountViewModel.logOff() }),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Log out",
|
||||||
|
modifier = Modifier.padding(vertical = 15.dp),
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = W500
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NavigationRow(navController: NavHostController, scaffoldState: ScaffoldState, route: Route) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val currentRoute = currentRoute(navController)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = 15.dp, horizontal = 25.dp)
|
||||||
|
.clickable(onClick = {
|
||||||
|
if (currentRoute != route.route) {
|
||||||
|
navController.navigate(route.route)
|
||||||
|
}
|
||||||
|
coroutineScope.launch {
|
||||||
|
scaffoldState.drawerState.close()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(route.icon), null,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
tint = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
text = route.route,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.navigation.NavBackStackEntry
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.HomeScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.MessageScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.NotificationScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.ProfileScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.SearchScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
|
|
||||||
|
sealed class Route(
|
||||||
|
val route: String,
|
||||||
|
val icon: Int,
|
||||||
|
val buildScreen: (AccountViewModel) -> @Composable (NavBackStackEntry) -> Unit
|
||||||
|
) {
|
||||||
|
object Home : Route("Home", R.drawable.ic_home, { acc -> { _ -> HomeScreen(acc) } })
|
||||||
|
object Search : Route("Search", R.drawable.ic_search, { acc -> { _ -> SearchScreen(acc) }})
|
||||||
|
object Notification : Route("Notification", R.drawable.ic_notifications, { acc -> { _ -> NotificationScreen(acc) }})
|
||||||
|
object Message : Route("Message", R.drawable.ic_dm, { acc -> { _ -> MessageScreen(acc) }})
|
||||||
|
object Profile : Route("Profile", R.drawable.ic_profile, { acc -> { _ -> ProfileScreen(acc) }})
|
||||||
|
object Lists : Route("Lists", R.drawable.ic_lists, { acc -> { _ -> ProfileScreen(acc) }})
|
||||||
|
object Topics : Route("Topics", R.drawable.ic_topics, { acc -> { _ -> ProfileScreen(acc) }})
|
||||||
|
object Bookmarks : Route("Bookmarks", R.drawable.ic_bookmarks, { acc -> { _ -> ProfileScreen(acc) }})
|
||||||
|
object Moments : Route("Moments", R.drawable.ic_moments, { acc -> { _ -> ProfileScreen(acc) }})
|
||||||
|
}
|
||||||
|
|
||||||
|
val Routes = listOf(
|
||||||
|
// bottom
|
||||||
|
Route.Home,
|
||||||
|
Route.Message,
|
||||||
|
Route.Search,
|
||||||
|
Route.Notification,
|
||||||
|
|
||||||
|
//drawer
|
||||||
|
Route.Profile,
|
||||||
|
Route.Lists,
|
||||||
|
Route.Topics,
|
||||||
|
Route.Bookmarks,
|
||||||
|
Route.Moments
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
public fun currentRoute(navController: NavHostController): String? {
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
return navBackStackEntry?.destination?.route
|
||||||
|
}
|
@ -0,0 +1,42 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.note
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.Divider
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) {
|
||||||
|
Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = 20.dp,
|
||||||
|
end = 20.dp,
|
||||||
|
bottom = 25.dp,
|
||||||
|
top = 15.dp
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Referenced event not found",
|
||||||
|
modifier = Modifier.padding(30.dp),
|
||||||
|
color = Color.Gray,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider(
|
||||||
|
modifier = Modifier.padding(vertical = 10.dp),
|
||||||
|
thickness = 0.25.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,92 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.note
|
||||||
|
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.text.format.DateUtils.getRelativeTimeSpanString
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.shape.CircleShape
|
||||||
|
import androidx.compose.material.Divider
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
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.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.google.accompanist.flowlayout.FlowRow
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.BoostSetCard
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import nostr.postr.events.TextNoteEvent
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BoostSetCompose(likeSetCard: BoostSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel) {
|
||||||
|
val noteState by likeSetCard.note.live.observeAsState()
|
||||||
|
val note = noteState?.note
|
||||||
|
|
||||||
|
if (note?.event == null) {
|
||||||
|
BlankNote(modifier, isInnerNote)
|
||||||
|
} else {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Row(modifier = Modifier.padding(horizontal = if (!isInnerNote) 12.dp else 0.dp)) {
|
||||||
|
|
||||||
|
// Draws the like picture outside the boosted card.
|
||||||
|
if (!isInnerNote) {
|
||||||
|
Box(modifier = Modifier
|
||||||
|
.width(55.dp)
|
||||||
|
.padding(0.dp)) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_retweeted),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(16.dp).align(Alignment.TopEnd),
|
||||||
|
tint = Color.Unspecified
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
|
||||||
|
FlowRow() {
|
||||||
|
likeSetCard.boostEvents.forEach {
|
||||||
|
val cardNoteState by it.live.observeAsState()
|
||||||
|
val cardNote = cardNoteState?.note
|
||||||
|
|
||||||
|
if (cardNote?.author != null) {
|
||||||
|
val userState by cardNote.author!!.live.observeAsState()
|
||||||
|
|
||||||
|
AsyncImage(
|
||||||
|
model = userState?.user?.profilePicture(),
|
||||||
|
contentDescription = "Profile Image",
|
||||||
|
modifier = Modifier
|
||||||
|
.width(35.dp)
|
||||||
|
.height(35.dp)
|
||||||
|
.clip(shape = CircleShape)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteCompose(note, modifier = Modifier.padding(top = 5.dp), isInnerNote = true, accountViewModel = accountViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,91 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.note
|
||||||
|
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.text.format.DateUtils.getRelativeTimeSpanString
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.shape.CircleShape
|
||||||
|
import androidx.compose.material.Divider
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
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.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.google.accompanist.flowlayout.FlowRow
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import nostr.postr.events.TextNoteEvent
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel) {
|
||||||
|
val noteState by likeSetCard.note.live.observeAsState()
|
||||||
|
val note = noteState?.note
|
||||||
|
|
||||||
|
if (note?.event == null) {
|
||||||
|
BlankNote(modifier, isInnerNote)
|
||||||
|
} else {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Row(modifier = Modifier.padding(horizontal = if (!isInnerNote) 12.dp else 0.dp)) {
|
||||||
|
|
||||||
|
// Draws the like picture outside the boosted card.
|
||||||
|
if (!isInnerNote) {
|
||||||
|
Box(modifier = Modifier
|
||||||
|
.width(55.dp)
|
||||||
|
.padding(0.dp)) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_liked),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(16.dp).align(Alignment.TopEnd),
|
||||||
|
tint = Color.Unspecified
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
|
||||||
|
FlowRow() {
|
||||||
|
likeSetCard.likeEvents.forEach {
|
||||||
|
val cardNoteState by it.live.observeAsState()
|
||||||
|
val cardNote = cardNoteState?.note
|
||||||
|
|
||||||
|
if (cardNote?.author != null) {
|
||||||
|
val userState by cardNote.author!!.live.observeAsState()
|
||||||
|
|
||||||
|
AsyncImage(
|
||||||
|
model = userState?.user?.profilePicture(),
|
||||||
|
contentDescription = "Profile Image",
|
||||||
|
modifier = Modifier
|
||||||
|
.width(35.dp)
|
||||||
|
.height(35.dp)
|
||||||
|
.clip(shape = CircleShape)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteCompose(note, modifier = Modifier.padding(top = 5.dp), isInnerNote = true, accountViewModel = accountViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
132
app/src/main/java/com/vitorpamplona/amethyst/ui/note/Note.kt
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.note
|
||||||
|
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.text.format.DateUtils.getRelativeTimeSpanString
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.Divider
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import nostr.postr.events.TextNoteEvent
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel) {
|
||||||
|
val noteState by baseNote.live.observeAsState()
|
||||||
|
val note = noteState?.note
|
||||||
|
|
||||||
|
if (note?.event == null) {
|
||||||
|
BlankNote(modifier, isInnerNote)
|
||||||
|
} else {
|
||||||
|
val authorState by note.author!!.live.observeAsState()
|
||||||
|
val author = authorState?.user
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Row(modifier = Modifier.padding(horizontal = if (!isInnerNote) 12.dp else 0.dp)) {
|
||||||
|
|
||||||
|
// Draws the boosted picture outside the boosted card.
|
||||||
|
if (!isInnerNote) {
|
||||||
|
Box(modifier = Modifier.width(55.dp).padding(0.dp)) {
|
||||||
|
AsyncImage(
|
||||||
|
model = author?.profilePicture(),
|
||||||
|
contentDescription = "Profile Image",
|
||||||
|
modifier = Modifier
|
||||||
|
.width(55.dp)
|
||||||
|
.clip(shape = CircleShape)
|
||||||
|
)
|
||||||
|
|
||||||
|
// boosted picture
|
||||||
|
val boostedPosts = note.replyTo
|
||||||
|
if (note.event is RepostEvent && boostedPosts != null && boostedPosts.isNotEmpty()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = boostedPosts[0].author?.profilePicture(),
|
||||||
|
contentDescription = "Profile Image",
|
||||||
|
modifier = Modifier
|
||||||
|
.width(35.dp)
|
||||||
|
.clip(shape = CircleShape)
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.background(MaterialTheme.colors.background)
|
||||||
|
.border(2.dp, MaterialTheme.colors.primary, CircleShape)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (author != null)
|
||||||
|
UserDisplay(author)
|
||||||
|
|
||||||
|
if (note.event !is RepostEvent) {
|
||||||
|
Text(
|
||||||
|
" " + timeAgo(note.event?.createdAt),
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
" boosted",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.event is TextNoteEvent && (note.replyTo != null || note.mentions != null)) {
|
||||||
|
ReplyInformation(note.replyTo, note.mentions)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.event is ReactionEvent || note.event is RepostEvent) {
|
||||||
|
note.replyTo?.mapIndexed { index, note ->
|
||||||
|
NoteCompose(
|
||||||
|
note,
|
||||||
|
modifier = Modifier.padding(top = 5.dp),
|
||||||
|
isInnerNote = true,
|
||||||
|
accountViewModel = accountViewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reposts have trash in their contents.
|
||||||
|
if (note.event is ReactionEvent) {
|
||||||
|
val refactorReactionText =
|
||||||
|
if (note.event?.content == "+") "❤" else note.event?.content ?: " "
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = refactorReactionText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val eventContent = note.event?.content
|
||||||
|
if (eventContent != null)
|
||||||
|
RichTextViewer(eventContent, note.event?.tags)
|
||||||
|
|
||||||
|
ReactionsRowState(note, accountViewModel)
|
||||||
|
|
||||||
|
Divider(
|
||||||
|
modifier = Modifier.padding(vertical = 10.dp),
|
||||||
|
thickness = 0.25.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.note
|
||||||
|
|
||||||
|
import nostr.postr.toHex
|
||||||
|
|
||||||
|
fun ByteArray.toDisplayHex(): String {
|
||||||
|
return toHex().toDisplayHex()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.toDisplayHex(): String {
|
||||||
|
return replaceRange(6, length-6, ":")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.note
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.IconButton
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
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.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReactionsRow(note: Note, account: Account, boost: (Note) -> Unit, reactTo: (Note) -> Unit) {
|
||||||
|
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
|
||||||
|
var wantsToReplyTo by remember {
|
||||||
|
mutableStateOf<Note?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantsToReplyTo != null)
|
||||||
|
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, account)
|
||||||
|
|
||||||
|
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||||
|
onClick = { if (account.isWriteable()) wantsToReplyTo = note }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_comment),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(15.dp),
|
||||||
|
tint = grayTint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
" ${showCount(note.replies?.size)}",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = grayTint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(start = 40.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||||
|
onClick = { if (account.isWriteable()) boost(note) }
|
||||||
|
) {
|
||||||
|
if (note.isBoostedBy(account.userProfile())) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_retweeted),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = Color.Unspecified
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_retweet),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = grayTint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
" ${showCount(note.boosts?.size)}",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(start = 40.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||||
|
onClick = { if (account.isWriteable()) reactTo(note) }
|
||||||
|
) {
|
||||||
|
if (note.isReactedBy(account.userProfile())) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_liked),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = Color.Unspecified
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_like),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = grayTint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
" ${showCount(note.reactions?.size)}",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(start = 40.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||||
|
onClick = { }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_share),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = grayTint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showCount(size: Int?): String {
|
||||||
|
if (size == null) return " "
|
||||||
|
return if (size == 0) return " " else "$size"
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.note
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReactionsRowState(baseNote: Note, accountViewModel: AccountViewModel) {
|
||||||
|
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||||
|
val account = accountState?.account
|
||||||
|
|
||||||
|
val noteState by baseNote.live.observeAsState()
|
||||||
|
val note = noteState?.note
|
||||||
|
|
||||||
|
if (account == null || note == null) return
|
||||||
|
|
||||||
|
ReactionsRow(note, account, accountViewModel::boost, accountViewModel::reactTo)
|
||||||
|
}
|
@ -0,0 +1,63 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.note
|
||||||
|
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.google.accompanist.flowlayout.FlowRow
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.model.User
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?) {
|
||||||
|
FlowRow() {
|
||||||
|
/*
|
||||||
|
if (replyTo != null && replyTo.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
" in reply to ",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
replyTo.toSet().forEachIndexed { idx, note ->
|
||||||
|
val innerNoteState by note.live.observeAsState()
|
||||||
|
Text(
|
||||||
|
"${innerNoteState?.note?.idDisplayHex}${if (idx < replyTo.size - 1) ", " else ""}",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
if (mentions != null && mentions.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
"replying to ",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
mentions.toSet().forEachIndexed { idx, user ->
|
||||||
|
val innerUserState by user.live.observeAsState()
|
||||||
|
Text(
|
||||||
|
"${innerUserState?.user?.toBestDisplayName()}",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (idx < mentions.size - 2) {
|
||||||
|
Text(
|
||||||
|
", ",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
} else if (idx < mentions.size - 1) {
|
||||||
|
Text(
|
||||||
|
" and ",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.note
|
||||||
|
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
|
||||||
|
fun timeAgo(mills: Long?): String {
|
||||||
|
if (mills == null) return " "
|
||||||
|
|
||||||
|
var humanReadable = DateUtils.getRelativeTimeSpanString(
|
||||||
|
mills * 1000,
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
DateUtils.MINUTE_IN_MILLIS,
|
||||||
|
DateUtils.FORMAT_ABBREV_ALL
|
||||||
|
).toString()
|
||||||
|
if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) {
|
||||||
|
humanReadable = "now";
|
||||||
|
}
|
||||||
|
return humanReadable
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.note
|
||||||
|
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import com.vitorpamplona.amethyst.model.User
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserDisplay(user: User) {
|
||||||
|
if (user.bestUsername() != null || user.bestDisplayName() != null) {
|
||||||
|
Text(
|
||||||
|
user.bestDisplayName() ?: "",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"@${(user.bestUsername() ?: "")}",
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
user.pubkeyDisplayHex,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLifecycleComposeApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AccountScreen(accountStateViewModel: AccountStateViewModel) {
|
||||||
|
val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
Column() {
|
||||||
|
Crossfade(targetState = accountState) { state ->
|
||||||
|
when (state) {
|
||||||
|
is AccountState.LoggedOff -> {
|
||||||
|
LoginPage(accountStateViewModel)
|
||||||
|
}
|
||||||
|
is AccountState.LoggedIn -> {
|
||||||
|
MainScreen(AccountViewModel(state.account), accountStateViewModel)
|
||||||
|
}
|
||||||
|
is AccountState.LoggedInViewOnly -> {
|
||||||
|
MainScreen(AccountViewModel(state.account), accountStateViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
|
||||||
|
sealed class AccountState {
|
||||||
|
object LoggedOff: AccountState()
|
||||||
|
class LoggedInViewOnly(val account: Account): AccountState()
|
||||||
|
class LoggedIn(val account: Account): AccountState()
|
||||||
|
}
|
@ -0,0 +1,106 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.model.toByteArray
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||||
|
import fr.acinq.secp256k1.Hex
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import nostr.postr.Persona
|
||||||
|
import nostr.postr.bechToBytes
|
||||||
|
import nostr.postr.toHex
|
||||||
|
|
||||||
|
class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPreferences): ViewModel() {
|
||||||
|
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)
|
||||||
|
val accountContent = _accountContent.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// pulls account from storage.
|
||||||
|
loadFromEncryptedStorage()?.let { login(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login(key: String) {
|
||||||
|
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
|
||||||
|
|
||||||
|
login(
|
||||||
|
if (key.startsWith("nsec")) {
|
||||||
|
Persona(privKey = key.bechToBytes())
|
||||||
|
} else if (key.startsWith("npub")) {
|
||||||
|
Persona(pubKey = key.bechToBytes())
|
||||||
|
} else if (pattern.matcher(key).matches()) {
|
||||||
|
// Evaluate NIP-5
|
||||||
|
Persona()
|
||||||
|
} else {
|
||||||
|
Persona(Hex.decode(key))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login(person: Persona) {
|
||||||
|
val loggedIn = Account(person)
|
||||||
|
|
||||||
|
if (person.privKey != null)
|
||||||
|
_accountContent.update { AccountState.LoggedIn ( loggedIn ) }
|
||||||
|
else
|
||||||
|
_accountContent.update { AccountState.LoggedInViewOnly ( Account(person) ) }
|
||||||
|
|
||||||
|
saveToEncryptedStorage(person)
|
||||||
|
|
||||||
|
NostrAccountDataSource.account = loggedIn
|
||||||
|
NostrHomeDataSource.account = loggedIn
|
||||||
|
NostrNotificationDataSource.account = loggedIn
|
||||||
|
|
||||||
|
NostrAccountDataSource.start()
|
||||||
|
NostrGlobalDataSource.start()
|
||||||
|
NostrHomeDataSource.start()
|
||||||
|
NostrNotificationDataSource.start()
|
||||||
|
NostrSingleEventDataSource.start()
|
||||||
|
NostrSingleUserDataSource.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun newKey() {
|
||||||
|
login(Persona())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logOff() {
|
||||||
|
_accountContent.update { AccountState.LoggedOff }
|
||||||
|
|
||||||
|
clearEncryptedStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearEncryptedStorage() {
|
||||||
|
encryptedPreferences.edit().apply {
|
||||||
|
remove("nostr_privkey")
|
||||||
|
remove("nostr_pubkey")
|
||||||
|
}.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveToEncryptedStorage(login: Persona) {
|
||||||
|
encryptedPreferences.edit().apply {
|
||||||
|
login.privKey?.let { putString("nostr_privkey", it.toHex()) }
|
||||||
|
login.pubKey.let { putString("nostr_pubkey", it.toHex()) }
|
||||||
|
}.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadFromEncryptedStorage(): Persona? {
|
||||||
|
encryptedPreferences.apply {
|
||||||
|
val privKey = getString("nostr_privkey", null)
|
||||||
|
val pubKey = getString("nostr_pubkey", null)
|
||||||
|
|
||||||
|
if (pubKey != null) {
|
||||||
|
return Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray())
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
|
||||||
|
abstract class Card() {
|
||||||
|
abstract fun createdAt(): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoteCard(val note: Note): Card() {
|
||||||
|
override fun createdAt(): Long {
|
||||||
|
return note.event?.createdAt ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LikeSetCard(val note: Note, val likeEvents: List<Note>): Card() {
|
||||||
|
val createdAt = likeEvents.maxOf { it.event?.createdAt ?: 0 }
|
||||||
|
|
||||||
|
override fun createdAt(): Long {
|
||||||
|
return createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BoostSetCard(val note: Note, val boostEvents: List<Note>): Card() {
|
||||||
|
val createdAt = boostEvents.maxOf { it.event?.createdAt ?: 0 }
|
||||||
|
|
||||||
|
override fun createdAt(): Long {
|
||||||
|
return createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class CardFeedState {
|
||||||
|
object Loading: CardFeedState()
|
||||||
|
class Loaded(val feed: List<Card>): CardFeedState()
|
||||||
|
object Empty: CardFeedState()
|
||||||
|
class FeedError(val errorMessage: String): CardFeedState()
|
||||||
|
}
|
@ -0,0 +1,93 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.Button
|
||||||
|
import androidx.compose.material.OutlinedButton
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
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.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||||
|
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.BoostSetCompose
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.LikeSetCompose
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLifecycleComposeApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewModel) {
|
||||||
|
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
|
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||||
|
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
LaunchedEffect(isRefreshing) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
viewModel.refresh()
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SwipeRefresh(
|
||||||
|
state = swipeRefreshState,
|
||||||
|
onRefresh = {
|
||||||
|
isRefreshing = true
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column() {
|
||||||
|
Crossfade(targetState = feedState) { state ->
|
||||||
|
when (state) {
|
||||||
|
is CardFeedState.Empty -> {
|
||||||
|
FeedEmpty {
|
||||||
|
isRefreshing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is CardFeedState.FeedError -> {
|
||||||
|
FeedError(state.errorMessage) {
|
||||||
|
isRefreshing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is CardFeedState.Loaded -> {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(
|
||||||
|
top = 10.dp,
|
||||||
|
bottom = 10.dp
|
||||||
|
),
|
||||||
|
state = listState
|
||||||
|
) {
|
||||||
|
itemsIndexed(state.feed) { index, item ->
|
||||||
|
when (item) {
|
||||||
|
is NoteCard -> NoteCompose(item.note, isInnerNote = false, accountViewModel = accountViewModel)
|
||||||
|
is LikeSetCard -> LikeSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel)
|
||||||
|
is BoostSetCard -> BoostSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CardFeedState.Loading -> {
|
||||||
|
LoadingFeed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,107 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCacheState
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class CardFeedViewModel(val dataSource: NostrDataSource): ViewModel() {
|
||||||
|
private val _feedContent = MutableStateFlow<CardFeedState>(CardFeedState.Loading)
|
||||||
|
val feedContent = _feedContent.asStateFlow()
|
||||||
|
|
||||||
|
private var lastNotes: List<Note>? = null
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
// For some reason, view Model Scope doesn't call
|
||||||
|
viewModelScope.launch {
|
||||||
|
refreshSuspend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshSuspend() {
|
||||||
|
val notes = dataSource.loadTop()
|
||||||
|
|
||||||
|
val lastNotesCopy = lastNotes
|
||||||
|
|
||||||
|
val oldNotesState = feedContent.value
|
||||||
|
if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) {
|
||||||
|
val newCards = convertToCard(notes.minus(lastNotesCopy))
|
||||||
|
if (newCards.isNotEmpty()) {
|
||||||
|
lastNotes = notes
|
||||||
|
updateFeed((oldNotesState.feed + newCards).sortedBy { it.createdAt() }.reversed())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val cards = convertToCard(notes)
|
||||||
|
lastNotes = notes
|
||||||
|
updateFeed(cards)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun convertToCard(notes: List<Note>): List<Card> {
|
||||||
|
val reactionsPerEvent = mutableMapOf<Note, MutableList<Note>>()
|
||||||
|
notes
|
||||||
|
.filter { it.event is ReactionEvent }
|
||||||
|
.forEach {
|
||||||
|
val reactedPost = it.replyTo?.last()
|
||||||
|
if (reactedPost != null)
|
||||||
|
reactionsPerEvent.getOrPut(reactedPost, { mutableListOf() }).add(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) }
|
||||||
|
|
||||||
|
val boostsPerEvent = mutableMapOf<Note, MutableList<Note>>()
|
||||||
|
notes
|
||||||
|
.filter { it.event is RepostEvent }
|
||||||
|
.forEach {
|
||||||
|
val boostedPost = it.replyTo?.last()
|
||||||
|
if (boostedPost != null)
|
||||||
|
boostsPerEvent.getOrPut(boostedPost, { mutableListOf() }).add(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
val boostCards = boostsPerEvent.map { BoostSetCard(it.key, it.value) }
|
||||||
|
|
||||||
|
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent }.map { NoteCard(it) }
|
||||||
|
|
||||||
|
return (reactionCards + boostCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateFeed(notes: List<Card>) {
|
||||||
|
if (notes.isEmpty()) {
|
||||||
|
_feedContent.update { CardFeedState.Empty }
|
||||||
|
} else {
|
||||||
|
_feedContent.update { CardFeedState.Loaded(notes) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshCurrentList() {
|
||||||
|
val state = feedContent.value
|
||||||
|
if (state is CardFeedState.Loaded) {
|
||||||
|
_feedContent.update { CardFeedState.Loaded(state.feed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cacheListener: (LocalCacheState) -> Unit = {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
LocalCache.live.observeForever(cacheListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
LocalCache.live.removeObserver(cacheListener)
|
||||||
|
|
||||||
|
dataSource.stop()
|
||||||
|
viewModelScope.cancel()
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
|
||||||
|
|
||||||
|
sealed class FeedState {
|
||||||
|
object Loading : FeedState()
|
||||||
|
class Loaded(val feed: List<Note>) : FeedState()
|
||||||
|
object Empty : FeedState()
|
||||||
|
class FeedError(val errorMessage: String) : FeedState()
|
||||||
|
}
|
@ -0,0 +1,188 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.Button
|
||||||
|
import androidx.compose.material.OutlinedButton
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
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.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||||
|
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLifecycleComposeApi::class)
|
||||||
|
@Composable
|
||||||
|
fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel) {
|
||||||
|
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
|
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||||
|
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
LaunchedEffect(isRefreshing) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
viewModel.refresh()
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SwipeRefresh(
|
||||||
|
state = swipeRefreshState,
|
||||||
|
onRefresh = {
|
||||||
|
isRefreshing = true
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column() {
|
||||||
|
Crossfade(targetState = feedState) { state ->
|
||||||
|
when (state) {
|
||||||
|
is FeedState.Empty -> {
|
||||||
|
FeedEmpty {
|
||||||
|
isRefreshing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is FeedState.FeedError -> {
|
||||||
|
FeedError(state.errorMessage) {
|
||||||
|
isRefreshing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is FeedState.Loaded -> {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(
|
||||||
|
top = 10.dp,
|
||||||
|
bottom = 10.dp
|
||||||
|
),
|
||||||
|
state = listState
|
||||||
|
) {
|
||||||
|
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
|
||||||
|
NoteCompose(item, isInnerNote = false, accountViewModel = accountViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FeedState.Loading -> {
|
||||||
|
LoadingFeed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoadingFeed() {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text("Loading feed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FeedError(errorMessage: String, onRefresh: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text("Error loading replies: $errorMessage")
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
|
onClick = onRefresh
|
||||||
|
) {
|
||||||
|
Text(text = "Try again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FeedEmpty(onRefresh: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text("Feed is empty.")
|
||||||
|
OutlinedButton(onClick = onRefresh) {
|
||||||
|
Text(text = "Refresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Bosted code to be deleted:
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
Boosted By: removed because it was ugly
|
||||||
|
|
||||||
|
if (item.event is RepostEvent) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = 12.dp,
|
||||||
|
end = 12.dp,
|
||||||
|
bottom = 8.dp
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_retweet),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = Color.Gray
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Boosted by ${item.author.toBestDisplayName()}",
|
||||||
|
modifier = Modifier.padding(start = 10.dp),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Gray,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val refNote = item.replyTo.firstOrNull()
|
||||||
|
if (refNote != null) {
|
||||||
|
NoteCompose(index, refNote)
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = 40.dp,
|
||||||
|
end = 40.dp,
|
||||||
|
bottom = 25.dp,
|
||||||
|
top = 15.dp
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Could not find referenced event",
|
||||||
|
modifier = Modifier.padding(30.dp),
|
||||||
|
color = Color.Gray,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NoteCompose(index, item)
|
||||||
|
}*/
|
@ -0,0 +1,71 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCacheState
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
||||||
|
class FeedViewModel(val dataSource: NostrDataSource): ViewModel() {
|
||||||
|
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
|
||||||
|
val feedContent = _feedContent.asStateFlow()
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
// For some reason, view Model Scope doesn't call
|
||||||
|
viewModelScope.launch {
|
||||||
|
refreshSuspend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshSuspend() {
|
||||||
|
val notes = dataSource.loadTop()
|
||||||
|
|
||||||
|
val oldNotesState = feedContent.value
|
||||||
|
if (oldNotesState is FeedState.Loaded) {
|
||||||
|
if (notes != oldNotesState.feed) {
|
||||||
|
updateFeed(notes)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateFeed(notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateFeed(notes: List<Note>) {
|
||||||
|
if (notes.isEmpty()) {
|
||||||
|
_feedContent.update { FeedState.Empty }
|
||||||
|
} else {
|
||||||
|
_feedContent.update { FeedState.Loaded(notes) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshCurrentList() {
|
||||||
|
val state = feedContent.value
|
||||||
|
if (state is FeedState.Loaded) {
|
||||||
|
_feedContent.update { FeedState.Loaded(state.feed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cacheListener: (LocalCacheState) -> Unit = {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
LocalCache.live.observeForever(cacheListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
LocalCache.live.removeObserver(cacheListener)
|
||||||
|
|
||||||
|
dataSource.stop()
|
||||||
|
viewModelScope.cancel()
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.Transformations
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||||
|
|
||||||
|
class RelayPoolViewModel: ViewModel() {
|
||||||
|
val relayPoolLiveData: LiveData<String> = Transformations.map(RelayPool.live) {
|
||||||
|
it.relays.report()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.Transformations
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.model.AccountState
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.model.UserState
|
||||||
|
|
||||||
|
class AccountViewModel(private val account: Account): ViewModel() {
|
||||||
|
val accountLiveData: LiveData<AccountState> = Transformations.map(account.live) { it }
|
||||||
|
val userLiveData: LiveData<UserState> = Transformations.map(account.userProfile().live) { it }
|
||||||
|
|
||||||
|
fun reactTo(note: Note) {
|
||||||
|
account.reactTo(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun boost(note: Note) {
|
||||||
|
account.boost(note)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLifecycleComposeApi::class)
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(accountViewModel: AccountViewModel) {
|
||||||
|
val account by accountViewModel.accountLiveData.observeAsState()
|
||||||
|
|
||||||
|
if (account != null) {
|
||||||
|
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrHomeDataSource ) }
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxHeight()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(vertical = 0.dp)
|
||||||
|
) {
|
||||||
|
FeedView(feedViewModel, accountViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,74 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.vitorpamplona.amethyst.buttons.NewNoteButton
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.AppTopBar
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.DrawerContent
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.currentRoute
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colors.primaryVariant)
|
||||||
|
.statusBarsPadding(),
|
||||||
|
bottomBar = {
|
||||||
|
AppBottomBar(navController)
|
||||||
|
},
|
||||||
|
topBar = {
|
||||||
|
AppTopBar(navController, scaffoldState, accountViewModel)
|
||||||
|
},
|
||||||
|
drawerContent = {
|
||||||
|
DrawerContent(navController, scaffoldState, accountViewModel, accountStateViewModel)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingButton(navController, accountStateViewModel)
|
||||||
|
},
|
||||||
|
scaffoldState = scaffoldState
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) {
|
||||||
|
AppNavigation(navController, accountViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLifecycleComposeApi::class)
|
||||||
|
@Composable
|
||||||
|
fun FloatingButton(navController: NavHostController, accountViewModel: AccountStateViewModel) {
|
||||||
|
val accountState by accountViewModel.accountContent.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
if (currentRoute(navController) == Route.Home.route) {
|
||||||
|
Crossfade(targetState = accountState) { state ->
|
||||||
|
when (state) {
|
||||||
|
is AccountState.LoggedInViewOnly -> {
|
||||||
|
// Does nothing.
|
||||||
|
}
|
||||||
|
is AccountState.LoggedOff -> {
|
||||||
|
// Does nothing.
|
||||||
|
}
|
||||||
|
is AccountState.LoggedIn -> {
|
||||||
|
NewNoteButton(state.account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.rememberScaffoldState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageScreen(accountViewModel: AccountViewModel) {
|
||||||
|
val state = rememberScaffoldState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text("Message Screen")
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLifecycleComposeApi::class)
|
||||||
|
@Composable
|
||||||
|
fun NotificationScreen(accountViewModel: AccountViewModel) {
|
||||||
|
val account by accountViewModel.accountLiveData.observeAsState()
|
||||||
|
|
||||||
|
if (account != null) {
|
||||||
|
val feedViewModel: CardFeedViewModel =
|
||||||
|
viewModel { CardFeedViewModel( NostrNotificationDataSource ) }
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxHeight()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(vertical = 0.dp)
|
||||||
|
) {
|
||||||
|
CardFeedView(feedViewModel, accountViewModel = accountViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.rememberScaffoldState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileScreen(accountViewModel: AccountViewModel) {
|
||||||
|
val state = rememberScaffoldState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text("Profile Screen")
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLifecycleComposeApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SearchScreen(accountViewModel: AccountViewModel) {
|
||||||
|
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrGlobalDataSource ) }
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxHeight()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(vertical = 0.dp)
|
||||||
|
) {
|
||||||
|
FeedView(feedViewModel, accountViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,104 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
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.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.Button
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.OutlinedTextField
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.Purple700
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginPage(accountViewModel: AccountStateViewModel) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
ClickableText(
|
||||||
|
text = AnnotatedString("Generate a new key"),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(20.dp),
|
||||||
|
onClick = { accountViewModel.newKey() },
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
color = Purple700
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp).fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val key = remember { mutableStateOf(TextFieldValue("")) }
|
||||||
|
|
||||||
|
Image(
|
||||||
|
painterResource(id = R.drawable.amethyst_logo),
|
||||||
|
contentDescription = "App Logo",
|
||||||
|
modifier = Modifier.size(300.dp),
|
||||||
|
contentScale = ContentScale.Inside
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
//Text(text = "Insert your private or public key (view-only)")
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = key.value,
|
||||||
|
onValueChange = { key.value = it },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
autoCorrect = false,
|
||||||
|
keyboardType = KeyboardType.Ascii,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "nsec / npub / hex private key",
|
||||||
|
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = { accountViewModel.login(key.value.text) },
|
||||||
|
shape = RoundedCornerShape(35.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(50.dp)
|
||||||
|
) {
|
||||||
|
Text(text = "Login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
val Purple200 = Color(0xFFBB86FC)
|
||||||
|
val Purple500 = Color(0xFF6200EE)
|
||||||
|
val Purple700 = Color(0xFF3700B3)
|
||||||
|
val Teal200 = Color(0xFF03DAC5)
|
@ -0,0 +1,11 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.Shapes
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
val Shapes = Shapes(
|
||||||
|
small = RoundedCornerShape(4.dp),
|
||||||
|
medium = RoundedCornerShape(4.dp),
|
||||||
|
large = RoundedCornerShape(0.dp)
|
||||||
|
)
|
@ -0,0 +1,56 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.theme
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.darkColors
|
||||||
|
import androidx.compose.material.lightColors
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
|
||||||
|
private val DarkColorPalette = darkColors(
|
||||||
|
primary = Purple200,
|
||||||
|
primaryVariant = Purple700,
|
||||||
|
secondary = Teal200,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightColorPalette = lightColors(
|
||||||
|
primary = Purple500,
|
||||||
|
primaryVariant = Purple700,
|
||||||
|
secondary = Teal200,
|
||||||
|
|
||||||
|
/* Other default colors to override
|
||||||
|
background = Color.White,
|
||||||
|
surface = Color.White,
|
||||||
|
onPrimary = Color.White,
|
||||||
|
onSecondary = Color.Black,
|
||||||
|
onBackground = Color.Black,
|
||||||
|
onSurface = Color.Black,
|
||||||
|
*/
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AmethystTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
|
||||||
|
val colors = if (darkTheme) {
|
||||||
|
DarkColorPalette
|
||||||
|
} else {
|
||||||
|
LightColorPalette
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colors = colors,
|
||||||
|
typography = Typography,
|
||||||
|
shapes = Shapes,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
|
||||||
|
val view = LocalView.current
|
||||||
|
if (!view.isInEditMode && darkTheme) {
|
||||||
|
SideEffect {
|
||||||
|
val window = (view.context as Activity).window
|
||||||
|
window.statusBarColor = colors.background.toArgb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
// Set of Material typography styles to start with
|
||||||
|
val Typography = Typography(
|
||||||
|
body1 = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
/* Other default text styles to override
|
||||||
|
button = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.W500,
|
||||||
|
fontSize = 14.sp
|
||||||
|
),
|
||||||
|
caption = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
)
|
BIN
app/src/main/res/drawable-hdpi/ic_amethyst.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_bookmarks.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_comment.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_compose.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_dm.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_home.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_like.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_liked.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_lists.png
Normal file
After Width: | Height: | Size: 927 B |
BIN
app/src/main/res/drawable-hdpi/ic_moments.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_notifications.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_profile.png
Normal file
After Width: | Height: | Size: 623 B |
BIN
app/src/main/res/drawable-hdpi/ic_qrcode.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_retweet.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_retweeted.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_search.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_share.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_theme.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_topics.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_trends.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_verified.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
app/src/main/res/drawable-mdpi/ic_amethyst.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
app/src/main/res/drawable-mdpi/ic_bookmarks.png
Normal file
After Width: | Height: | Size: 936 B |
BIN
app/src/main/res/drawable-mdpi/ic_comment.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/drawable-mdpi/ic_compose.png
Normal file
After Width: | Height: | Size: 890 B |
BIN
app/src/main/res/drawable-mdpi/ic_dm.png
Normal file
After Width: | Height: | Size: 1.0 KiB |