Compare commits

...
67 Commits
Author SHA1 Message Date
Vitor Pamplona c5aa57d91b v0.13.0 2023-02-04 19:44:47 -05:00
Vitor Pamplona 229f15ee7f Adds automatic translation to feed and chat. 2023-02-04 19:43:31 -05:00
Vitor Pamplona d168a6c861 Changes to Logging 2023-02-04 19:43:08 -05:00
Vitor Pamplona 39d58452a0 Allows redirection on relay URIs 2023-02-04 19:42:08 -05:00
Vitor Pamplona b334c7105c Adds a relay filter to download all reports by the user 2023-02-04 19:41:47 -05:00
Vitor Pamplona ea9ff08d8a Adds hidden notes to chat. 2023-02-04 19:41:19 -05:00
Vitor Pamplona e3d7c23218 Moves QR code classes to another package 2023-02-04 19:40:45 -05:00
Vitor Pamplona 67b030f068 Matches target API with build file 2023-02-04 15:10:16 -05:00
Vitor Pamplona 1940303130 Increases timeout period and avoid double websocket connections. 2023-02-04 13:32:09 -05:00
Vitor Pamplona 80bc09df92 refactoring 2023-02-04 13:30:39 -05:00
Vitor Pamplona ea59568b6f removes mutable list in Client. 2023-02-04 13:30:15 -05:00
Vitor Pamplona 3e08256347 Keeping subscription names small to please some relays. 2023-02-04 13:29:53 -05:00
Vitor Pamplona 2641dda7bc Fixes Follow/Unfollow button update after pressing. 2023-02-04 10:48:54 -05:00
Vitor Pamplona 40deb46eab Bugfix: consider flags of the referenced post in case of Boost events. 2023-02-04 10:42:32 -05:00
Vitor Pamplona 2a5f8595d0 Increasing Notification stack to 200 2023-02-04 10:41:58 -05:00
Vitor Pamplona 6c27fe0828 Adding more info into flagged posts. 2023-02-04 10:41:43 -05:00
Vitor Pamplona 2992d9daf0 Trying to fix NPE around these lines. 2023-02-04 10:41:01 -05:00
Vitor Pamplona bbfd060328 v0.12.1 2023-02-03 17:46:24 -05:00
Vitor Pamplona 9e894642ec Correctly aligns chat field. 2023-02-03 17:45:44 -05:00
Vitor Pamplona 22daf1a6a8 Removes Channels from the Messages Notification Dot. 2023-02-03 17:45:28 -05:00
Vitor Pamplona 1a3b92a727 Abolishes the use of Mutable collections inside data models. 2023-02-03 17:23:47 -05:00
Vitor Pamplona ee7120d803 Adding a way to visualize view count stats 2023-02-03 11:26:49 -05:00
Vitor Pamplona a45a8789e7 Faster animations 2023-02-03 11:26:28 -05:00
Vitor PamplonaandGitHub bb3e38d079 Merge pull request #63 from clackbib/disable_backup
Disable android backup as this app uses EncryptedSharedPrefs. Usage o…
2023-02-03 09:46:54 -05:00
Vitor Pamplona 1568cad693 Fixing test cases 2023-02-03 09:08:06 -05:00
Vitor Pamplona 10e5c43be9 v0.12.0 2023-02-02 20:40:03 -05:00
Vitor Pamplona e3aa573b8c Removing logs 2023-02-02 20:39:54 -05:00
Vitor Pamplona bd77c43d40 Not reloading reactions to an event. 2023-02-02 20:18:06 -05:00
Vitor Pamplona 8e02f67052 Reducing the chance of a bug when loading chatroom without a user 2023-02-02 20:17:53 -05:00
Vitor Pamplona 299cf1aabb Keeping the report filter. 2023-02-02 20:16:35 -05:00
Vitor Pamplona 343521ff58 Cut off of 200 messages for each channel 2023-02-02 20:16:07 -05:00
Vitor Pamplona a82f260142 Capturing EOSE events and keeping messages within relay limits (10 subscriptions and 10 REQ events at a time) 2023-02-02 16:57:03 -05:00
Vitor Pamplona fb73308995 Moving observable classes to the leaf nodes. 2023-02-02 14:34:34 -05:00
Vitor Pamplona b3ce10c2de Allowing HTTP downloads 2023-02-02 14:30:58 -05:00
Vitor Pamplona 4fa6873180 Forcing Notifications with Direct citations only removes too much. 2023-02-02 14:29:39 -05:00
Habib Okanla a34e88b4fc Disable android backup as this app uses EncryptedSharedPrefs. Usage of EncryptedSharedPrefs can cause crashes if it's not properly excluded from backup.
https://stackoverflow.com/questions/61751264/autobackup-with-encryptedsharedpreferences-failing-to-restore
2023-02-01 19:33:46 -05:00
Vitor Pamplona 912ca72d68 Presenting a Drop Down menu to copy keys and channel IDs. 2023-02-01 14:05:59 -03:00
Vitor Pamplona c3c19ebb49 Only showing notifications if it directly cites the account holder 2023-02-01 13:51:03 -03:00
Vitor Pamplona 0f968ca5e5 Removing channel create and change from Notifications Feed 2023-02-01 13:17:21 -03:00
Vitor Pamplona 2e17535a60 Keeping chatroom list scroll up top if it was up top. 2023-02-01 12:40:31 -03:00
Vitor Pamplona 663eb9c604 Solves https://github.com/vitorpamplona/amethyst/issues/58 2023-02-01 10:52:54 -03:00
Vitor Pamplona 4b894c5b33 Fixes bug where default relays would come back after leaving the two groups and not adding any other group. 2023-01-31 23:42:14 -03:00
Vitor Pamplona 814fb1845f Moving Picture and Display Name Observables to leaf UI nodes. 2023-01-31 22:12:24 -03:00
Vitor Pamplona 94a4bfc2f3 Avoiding calculations in the UI thread 2023-01-31 22:11:54 -03:00
Vitor Pamplona 4303e6040c Fixes Channel @tag 2023-01-31 20:50:56 -03:00
Vitor Pamplona 75cb313886 Uses Hex to idex Users 2023-01-31 20:50:38 -03:00
Vitor Pamplona 88d2786473 v0.11.7 2023-01-30 23:39:30 -03:00
Vitor Pamplona 7837860ac6 Keep Note model refreshes in the Default thread. 2023-01-30 23:39:09 -03:00
Vitor Pamplona b7bc8a0b9d Fixing Boost to add content of the post in the boosted event as well as selecting only the last one to show on the screen. 2023-01-30 23:36:06 -03:00
Vitor Pamplona c71c121356 Correctly updates new changes to the User model 2023-01-30 22:41:13 -03:00
Vitor Pamplona d94f35de0e User Relay information in Profiles 2023-01-30 22:05:48 -03:00
Vitor Pamplona 3c52ff6e8d Tracks where notes are coming from 2023-01-30 19:38:06 -03:00
Vitor Pamplona dc47845dd6 Solves a duplicated id in the feed. 2023-01-30 19:36:39 -03:00
Vitor Pamplona 76087e5f25 Removes new threads, boosts from the Conversations view 2023-01-30 13:51:30 -03:00
Vitor Pamplona 6225b4f843 Renames Follows + Replies to Conversations 2023-01-30 13:51:10 -03:00
Vitor Pamplona 3a834a932c Increases Home feed to 400 2023-01-30 13:50:56 -03:00
Vitor Pamplona e59dfa7a76 Avoids concurrent exceptions 2023-01-30 13:50:43 -03:00
Vitor Pamplona a0ba313661 Creating linkable routes for NIP19 in content. 2023-01-30 13:50:24 -03:00
Vitor Pamplona fb8d6daf3d Resizing when using the keyboard 2023-01-30 00:23:22 -03:00
Vitor Pamplona f2d7dea860 v0.11.6 Avoids crashing on invalid relay entries. 2023-01-29 23:35:08 -03:00
Vitor Pamplona 29b1a94ec4 Fixing crash due to wrong relay url (spaces, wrong schema, etc) 2023-01-29 23:27:48 -03:00
Vitor Pamplona bba5afcdbf 0.11.5 2023-01-29 22:07:00 -03:00
Vitor Pamplona 8b1e0f9af0 Activating NIP 56 (Report Users and Posts with Event Kind 1984) 2023-01-29 22:06:48 -03:00
Vitor Pamplona 9fe73c7a97 v0.11.4 QR code reading and scanning (any NIP-21 uri) 2023-01-29 00:58:43 -03:00
Vitor Pamplona 9263684031 Support for PubKey QR showing and Scanning. 2023-01-29 00:56:13 -03:00
Vitor Pamplona d1753a59f9 v0.11.3 2023-01-28 12:42:37 -03:00
Vitor Pamplona 31e2e586b7 Adding Block user option in profiles. 2023-01-28 12:42:24 -03:00
79 changed files with 2841 additions and 944 deletions
+20 -3
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 28
versionName "0.11.2"
versionCode 36
versionName "0.13.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -85,7 +85,7 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
// Json Serialization
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.1'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2'
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
@@ -110,6 +110,23 @@ dependencies {
implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
// For QR generation
implementation 'com.google.zxing:core:3.5.1'
implementation "androidx.camera:camera-camera2:1.2.1"
implementation 'androidx.camera:camera-lifecycle:1.2.1'
implementation 'androidx.camera:camera-view:1.2.1'
// For QR Scanning
implementation 'com.google.mlkit:vision-common:17.3.0'
implementation 'com.google.mlkit:barcode-scanning:17.0.3'
// Use this dependency to use the dynamically downloaded model in Google Play Services
implementation 'com.google.android.gms:play-services-mlkit-language-id:17.0.0'
// Use this dependency to use the translate text
implementation 'com.google.mlkit:translate:17.0.1'
implementation 'com.google.mlkit:language-id-common:16.1.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
@@ -6,6 +6,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
import org.junit.Assert.*
import org.junit.Test
@@ -27,7 +28,7 @@ class EUrlUserTagTransformationTest {
@Test
fun transformationText() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"))
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
user.info.displayName = "Vitor Pamplona"
var transformedText = buildAnnotatedStringWithUrlHighlighting(
@@ -61,7 +62,7 @@ class EUrlUserTagTransformationTest {
@Test
fun transformationTextTwoKeys() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"))
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
user.info.displayName = "Vitor Pamplona"
var transformedText = buildAnnotatedStringWithUrlHighlighting(
+13 -4
View File
@@ -4,9 +4,10 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.CAMERA" />
<application
android:allowBackup="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/amethyst"
@@ -15,17 +16,21 @@
android:supportsRtl="true"
android:theme="@style/Theme.Amethyst"
android:largeHeap="true"
tools:targetApi="31">
android:usesCleartextTraffic="true"
tools:targetApi="33">
<activity
android:label="Amethyst"
android:name=".ui.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Amethyst">
<intent-filter>
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
@@ -36,6 +41,10 @@
android:name="android.app.lib_name"
android:value="" />
</activity>
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode" />
</application>
</manifest>
@@ -35,7 +35,7 @@ class LocalPreferences(context: Context) {
encryptedPreferences.apply {
val privKey = getString("nostr_privkey", null)
val pubKey = getString("nostr_pubkey", null)
val followingChannels = getStringSet("following_channels", DefaultChannels)?.toMutableSet() ?: DefaultChannels.toMutableSet()
val followingChannels = getStringSet("following_channels", null)?.toMutableSet() ?: mutableSetOf()
val hiddenUsers = getStringSet("hidden_users", emptySet())?.toMutableSet() ?: mutableSetOf()
if (pubKey != null) {
@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
@@ -33,12 +34,12 @@ val DefaultChannels = setOf(
class Account(
val loggedIn: Persona,
val followingChannels: MutableSet<String> = DefaultChannels.toMutableSet(),
val hiddenUsers: MutableSet<String> = mutableSetOf()
var followingChannels: Set<String> = DefaultChannels.toMutableSet(),
var hiddenUsers: Set<String> = mutableSetOf()
) {
fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey)
return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
}
fun followingChannels(): List<Channel> {
@@ -46,7 +47,7 @@ class Account(
}
fun hiddenUsers(): List<User> {
return hiddenUsers.map { LocalCache.getOrCreateUser(it.toByteArray()) }
return hiddenUsers.map { LocalCache.getOrCreateUser(it) }
}
fun isWriteable(): Boolean {
@@ -90,7 +91,7 @@ class Account(
fun reactTo(note: Note) {
if (!isWriteable()) return
if (note.reactions.firstOrNull { it.author == userProfile() && it.event?.content == "+" } != null) {
if (note.hasReacted(userProfile(), "+")) {
// has already liked this note
return
}
@@ -102,12 +103,10 @@ class Account(
}
}
fun report(note: Note) {
fun report(note: Note, type: ReportEvent.ReportType) {
if (!isWriteable()) return
if (
note.reactions.firstOrNull { it.author == userProfile() && it.event?.content == "⚠️"} != null
) {
if (note.hasReacted(userProfile(), "⚠️")) {
// has already liked this note
return
}
@@ -117,16 +116,31 @@ class Account(
Client.send(event)
LocalCache.consume(event)
}
note.event?.let {
val event = ReportEvent.create(it, type, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
fun report(user: User, type: ReportEvent.ReportType) {
if (!isWriteable()) return
if (user.hasReport(userProfile(), type)) {
// has already reported this note
return
}
val event = ReportEvent.create(user.pubkeyHex, type, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
fun boost(note: Note) {
if (!isWriteable()) return
val currentTime = Date().time / 1000
if (
note.boosts.firstOrNull { it.author == userProfile() && (it?.event?.createdAt ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection
) {
if (note.hasBoosted(userProfile())) {
// has already bosted in the past 5mins
return
}
@@ -247,22 +261,22 @@ class Account(
}
fun joinChannel(idHex: String) {
followingChannels.add(idHex)
followingChannels = followingChannels + idHex
invalidateData()
}
fun leaveChannel(idHex: String) {
followingChannels.remove(idHex)
followingChannels = followingChannels - idHex
invalidateData()
}
fun hideUser(pubkeyHex: String) {
hiddenUsers.add(pubkeyHex)
hiddenUsers = hiddenUsers + pubkeyHex
invalidateData()
}
fun showUser(pubkeyHex: String) {
hiddenUsers.remove(pubkeyHex)
hiddenUsers = hiddenUsers - pubkeyHex
invalidateData()
}
@@ -282,7 +296,7 @@ class Account(
Client.send(event)
LocalCache.consume(event)
followingChannels.add(event.id.toHex())
joinChannel(event.id.toHex())
}
fun decryptContent(note: Note): String? {
@@ -347,23 +361,41 @@ class Account(
}
}
fun isHidden(user: User) = user in hiddenUsers()
fun isAcceptable(user: User): Boolean {
return user !in hiddenUsers() // if user hasn't hided this author
&& user.reportsBy( userProfile() ).isEmpty() // if user has not reported this post
&& user.reportsBy( userProfile().follows ).size < 5
}
fun isAcceptableDirect(note: Note): Boolean {
return note.reports.firstOrNull { it.author == userProfile() } == null // if user has not reported this post
&& note.reports.filter { it.author in userProfile().follows }.size < 5 // if it has 5 reports by reliable users
return note.reportsBy( userProfile() ).isEmpty() // if user has not reported this post
&& note.reportsBy( userProfile().follows ).size < 5 // if it has 5 reports by reliable users
}
fun isAcceptable(note: Note): Boolean {
return note.author?.let { isAcceptable(it) } ?: true // if user hasn't hided this author
&& isAcceptableDirect(note)
&& (note.event !is RepostEvent
|| (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(note) } != null)
|| (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null)
) // is not a reaction about a blocked post
}
fun getRelevantReports(note: Note): Set<Note> {
val followsPlusMe = userProfile().follows + userProfile()
val innerReports = if (note.event is RepostEvent) {
note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList()
} else {
emptyList()
}
return (note.reportsBy(followsPlusMe) +
(note.author?.reportsBy(followsPlusMe) ?: emptyList()) +
innerReports).toSet()
}
}
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {
@@ -17,6 +17,10 @@ class Channel(val id: ByteArray) {
val notes = ConcurrentHashMap<HexKey, Note>()
fun toBestDisplayName(): String {
return info.name ?: idDisplayHex
}
@Synchronized
fun addNote(note: Note) {
notes[note.idHex] = note
@@ -11,6 +11,17 @@ import nostr.postr.toHex
/** Makes the distinction between String and Hex **/
typealias HexKey = String
typealias NPubKey = String
typealias NoteId = String
fun NPubKey.toDisplayKey(): String {
return this.toShortenHex()
}
fun NoteId.toDisplayId(): String {
return this.toShortenHex()
}
fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.Bech32)
fun ByteArray.toHexKey(): HexKey {
@@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.io.ByteArrayInputStream
import java.time.Instant
@@ -18,6 +19,7 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import com.vitorpamplona.amethyst.service.relays.Relay
import nostr.postr.events.ContactListEvent
import nostr.postr.events.DeletionEvent
import nostr.postr.events.Event
@@ -39,10 +41,9 @@ object LocalCache {
val channels = ConcurrentHashMap<HexKey, Channel>()
@Synchronized
fun getOrCreateUser(pubkey: ByteArray): User {
val key = pubkey.toHexKey()
fun getOrCreateUser(key: HexKey): User {
return users[key] ?: run {
val answer = User(pubkey)
val answer = User(key)
users.put(key, answer)
answer
}
@@ -69,10 +70,8 @@ object LocalCache {
fun consume(event: MetadataEvent) {
// new event
val oldUser = getOrCreateUser(event.pubKey)
val oldUser = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > oldUser.updatedMetadataAt) {
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
val newUser = try {
metadataParser.readValue<UserMetadata>(ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)), UserMetadata::class.java)
} catch (e: Exception) {
@@ -83,6 +82,8 @@ object LocalCache {
oldUser.updateUserInfo(newUser, event.createdAt)
oldUser.latestMetadata = event
//Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
} else {
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
@@ -93,22 +94,26 @@ object LocalCache {
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
}
fun consume(event: TextNoteEvent) {
fun consume(event: TextNoteEvent, relay: Relay? = null) {
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
if (relay != null)
author.addRelay(relay, event.createdAt)
// 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())
val mentions = event.mentions.map { getOrCreateUser(it) }
val replyTo = event.replyTos.map { getOrCreateNote(it) }
note.loadEvent(event, author, mentions, replyTo)
//Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content?.take(100)} ${formattedDateTime(event.createdAt)}")
// Prepares user's profile view.
author.notes.add(note)
author.addNote(note)
// Adds notifications to users.
mentions.forEach {
@@ -131,7 +136,7 @@ object LocalCache {
}
fun consume(event: ContactListEvent) {
val user = getOrCreateUser(event.pubKey)
val user = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > user.updatedFollowsAt) {
//Log.d("CL", "AAA ${user.toBestDisplayName()} ${event.follows.size}")
@@ -139,7 +144,7 @@ object LocalCache {
event.follows.map {
try {
val pubKey = decodePublicKey(it.pubKeyHex)
getOrCreateUser(pubKey)
getOrCreateUser(pubKey.toHexKey())
} catch (e: Exception) {
println("Could not parse Hex key: ${it.pubKeyHex}")
println("UpdateFollows: " + event.toJson())
@@ -173,13 +178,13 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val recipient = event.recipientPubKey?.let { getOrCreateUser(it) }
val author = getOrCreateUser(event.pubKey.toHexKey())
val recipient = event.recipientPubKey?.let { getOrCreateUser(it.toHexKey()) }
//Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateNote(it) }.toMutableList()
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateUser(decodePublicKey(it)) }
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateNote(it) }
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateUser(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -203,14 +208,14 @@ object LocalCache {
//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()
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.originalAuthor.map { getOrCreateUser(it) }
val repliesTo = event.boostedPost.map { getOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
// Prepares user's profile view.
author.notes.add(note)
author.addNote(note)
// Adds notifications to users.
mentions.forEach {
@@ -229,14 +234,14 @@ object LocalCache {
}
fun consume(event: ReactionEvent) {
val note = getOrCreateNote(event.id.toHex())
val note = getOrCreateNote(event.id.toHexKey())
// 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()
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.originalAuthor.map { getOrCreateUser(it) }
val repliesTo = event.originalPost.map { getOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -273,11 +278,33 @@ object LocalCache {
}
}
fun consume(event: ReportEvent) {
val note = getOrCreateNote(event.id.toHex())
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.reportedAuthor.map { getOrCreateUser(it) }
val repliesTo = event.reportedPost.map { getOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
//Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addReport(note)
}
repliesTo.forEach {
it.addReport(note)
}
}
fun consume(event: ChannelCreateEvent) {
//Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
// new event
val oldChannel = getOrCreateChannel(event.id.toHex())
val author = getOrCreateUser(event.pubKey)
val author = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo, event.createdAt)
@@ -285,7 +312,7 @@ object LocalCache {
val note = getOrCreateNote(event.id.toHex())
oldChannel.addNote(note)
note.channel = oldChannel
note.loadEvent(event, author, emptyList(), mutableListOf())
note.loadEvent(event, author, emptyList(), emptyList())
refreshObservers()
}
@@ -299,7 +326,7 @@ object LocalCache {
// new event
val oldChannel = getOrCreateChannel(event.channel)
val author = getOrCreateUser(event.pubKey)
val author = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo, event.createdAt)
@@ -307,7 +334,7 @@ object LocalCache {
val note = getOrCreateNote(event.id.toHex())
oldChannel.addNote(note)
note.channel = oldChannel
note.loadEvent(event, author, emptyList(), mutableListOf())
note.loadEvent(event, author, emptyList(), emptyList())
refreshObservers()
}
@@ -327,14 +354,11 @@ object LocalCache {
// 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) }
.filter { it.event !is ChannelCreateEvent }
.toMutableList()
)
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.mentions.map { getOrCreateUser(it) }
val replyTo = event.replyTos
.map { getOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
note.channel = channel
note.loadEvent(event, author, mentions, replyTo)
@@ -1,52 +1,62 @@
package com.vitorpamplona.amethyst.model
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Collections
import java.util.Date
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.service.relays.Relay
import java.util.Date
import java.util.regex.Matcher
import java.util.regex.Pattern
import nostr.postr.events.Event
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
class Note(val idHex: String) {
// These fields are always available.
// They are immutable
val id = Hex.decode(idHex)
val idDisplayHex = id.toShortenHex()
val idDisplayNote = id.toNote().toShortenHex()
// These fields are only available after the Text Note event is received.
// They are immutable after that.
var event: Event? = null
var author: User? = null
var mentions: List<User>? = null
var replyTo: MutableList<Note>? = null
var replyTo: List<Note>? = null
// These fields are updated every time an event related to this note is received.
val replies = Collections.synchronizedSet(mutableSetOf<Note>())
val reactions = Collections.synchronizedSet(mutableSetOf<Note>())
val boosts = Collections.synchronizedSet(mutableSetOf<Note>())
val reports = Collections.synchronizedSet(mutableSetOf<Note>())
var replies = setOf<Note>()
private set
var reactions = setOf<Note>()
private set
var boosts = setOf<Note>()
private set
var reports = setOf<Note>()
private set
var channel: Channel? = null
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: MutableList<Note>) {
var lastReactionsDownloadTime: Long? = null
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: List<Note>) {
this.event = event
this.author = author
this.mentions = mentions
this.replyTo = replyTo
invalidateData(live)
live.invalidateData()
}
fun formattedDateTime(timestamp: Long): String {
@@ -77,35 +87,83 @@ class Note(val idHex: String) {
}
fun addReply(note: Note) {
if (replies.add(note))
invalidateData(liveReplies)
if (note !in replies) {
replies = replies + note
liveReplies.invalidateData()
}
}
fun addBoost(note: Note) {
if (boosts.add(note))
invalidateData(liveBoosts)
if (note !in boosts) {
boosts = boosts + note
liveBoosts.invalidateData()
}
}
fun addReaction(note: Note) {
if (reactions.add(note))
invalidateData(liveReactions)
if (note !in reactions) {
reactions = reactions + note
liveReactions.invalidateData()
}
}
fun addReport(note: Note) {
if (reports.add(note))
invalidateData(liveReports)
if (note !in reports) {
reports = reports + note
liveReports.invalidateData()
}
}
fun isReactedBy(user: User): Boolean {
return synchronized(reactions) {
reactions.any { it.author == user }
}
return reactions.any { it.author == user }
}
fun isBoostedBy(user: User): Boolean {
return synchronized(boosts) {
boosts.any { it.author == user }
return boosts.any { it.author == user }
}
fun reportsBy(user: User): List<Note> {
return reports.filter { it.author == user }
}
fun reportsBy(users: Set<User>): List<Note> {
return reports.filter { it.author in users }
}
fun directlyCiteUsers(): Set<User> {
val matcher = tagSearch.matcher(event?.content ?: "")
val returningList = mutableSetOf<User>()
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event?.tags?.get(it.toInt()) }
if (tag != null && tag[0] == "p") {
returningList.add(LocalCache.getOrCreateUser(tag[1]))
}
} catch (e: Exception) {
}
}
return returningList
}
fun directlyCites(userProfile: User): Boolean {
return author == userProfile
|| (userProfile in directlyCiteUsers())
|| (event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
|| (event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
}
fun isNewThread(): Boolean {
return event is RepostEvent || replyTo == null || replyTo?.size == 0
}
fun hasReacted(loggedIn: User, content: String): Boolean {
return reactions.firstOrNull { it.author == loggedIn && it.event?.content == content } != null
}
fun hasBoosted(loggedIn: User): Boolean {
val currentTime = Date().time / 1000
return boosts.firstOrNull { it.author == loggedIn && (it.event?.createdAt ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection
}
// Observers line up here.
@@ -115,24 +173,24 @@ class Note(val idHex: String) {
val liveBoosts: NoteLiveData = NoteLiveData(this)
val liveReplies: NoteLiveData = NoteLiveData(this)
val liveReports: NoteLiveData = NoteLiveData(this)
}
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData(live: NoteLiveData) {
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
refresh()
handlerWaiting = false
}
}
}
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
fun refresh() {
postValue(NoteState(note))
}
@@ -14,8 +14,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object UrlCachedPreviewer {
val cache = ConcurrentHashMap<String, UrlInfoItem>()
val failures = ConcurrentHashMap<String, Throwable>()
var cache = mapOf<String, UrlInfoItem>()
private set
var failures = mapOf<String, Throwable>()
private set
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
cache[url]?.let {
@@ -32,12 +34,12 @@ object UrlCachedPreviewer {
scope.launch {
BahaUrlPreview(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
cache.put(url, urlInfo)
cache = cache + Pair(url, urlInfo)
callback?.onComplete(urlInfo)
}
override fun onFailed(throwable: Throwable) {
failures.put(url, throwable)
failures = failures + Pair(url, throwable)
callback?.onFailed(throwable)
}
}).fetchUrlPreview()
@@ -2,9 +2,11 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
@@ -15,10 +17,11 @@ import kotlinx.coroutines.launch
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.toNpub
class User(val pubkey: ByteArray) {
val pubkeyHex = pubkey.toHexKey()
val pubkeyDisplayHex = pubkey.toShortenHex()
class User(val pubkeyHex: String) {
val pubkey = Hex.decode(pubkeyHex)
val pubkeyDisplayHex = pubkey.toNpub().toShortenHex()
var info = UserMetadata()
@@ -28,15 +31,30 @@ class User(val pubkey: ByteArray) {
var latestContactList: ContactListEvent? = null
var latestMetadata: MetadataEvent? = null
val notes = Collections.synchronizedSet(mutableSetOf<Note>())
val follows = Collections.synchronizedSet(mutableSetOf<User>())
var follows = setOf<User>()
private set
var followers = setOf<User>()
private set
val taggedPosts = Collections.synchronizedSet(mutableSetOf<Note>())
var notes = setOf<Note>()
private set
var taggedPosts = setOf<Note>()
private set
var reports = setOf<Note>()
private set
var relays: Map<String, ContactListEvent.ReadWrite>? = null
private set
val followers = Collections.synchronizedSet(mutableSetOf<User>())
val messages = ConcurrentHashMap<User, MutableSet<Note>>()
var relaysBeingUsed = mapOf<String, RelayInfo>()
private set
var messages = mapOf<User, Set<Note>>()
private set
var latestMetadataRequestEOSE: Long? = null
var latestReportRequestEOSE: Long? = null
fun toBestDisplayName(): String {
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex
@@ -56,22 +74,22 @@ class User(val pubkey: ByteArray) {
}
fun follow(user: User, followedAt: Long) {
follows.add(user)
user.followers.add(this)
follows = follows + user
user.followers = user.followers + this
invalidateData()
user.invalidateData()
liveFollows.invalidateData()
user.liveFollows.invalidateData()
listeners.forEach {
it.onFollowsChange()
}
}
fun unfollow(user: User) {
follows.remove(user)
user.followers.remove(this)
follows = follows - user
user.followers = user.followers - this
invalidateData()
user.invalidateData()
liveFollows.invalidateData()
user.liveFollows.invalidateData()
updateSubscribers {
it.onFollowsChange()
@@ -79,32 +97,77 @@ class User(val pubkey: ByteArray) {
}
fun addTaggedPost(note: Note) {
taggedPosts.add(note)
updateSubscribers { it.onNewPosts() }
if (note !in taggedPosts) {
taggedPosts = taggedPosts + note
updateSubscribers { it.onNewTaggedPosts() }
}
}
fun addNote(note: Note) {
if (note !in notes) {
notes = notes + note
updateSubscribers { it.onNewNotes() }
}
}
fun addReport(note: Note) {
if (note !in reports) {
reports = reports + note
liveReports.invalidateData()
}
}
fun reportsBy(user: User): List<Note> {
return reports.filter { it.author == user }
}
fun reportsBy(users: Set<User>): List<Note> {
return reports.filter { it.author in users }
}
@Synchronized
fun getOrCreateChannel(user: User): MutableSet<Note> {
fun getOrCreateChannel(user: User): Set<Note> {
return messages[user] ?: run {
val channel = Collections.synchronizedSet(mutableSetOf<Note>())
messages[user] = channel
val channel = setOf<Note>()
messages = messages + Pair(user, channel)
channel
}
}
fun addMessage(user: User, msg: Note) {
getOrCreateChannel(user).add(msg)
live.refresh()
updateSubscribers { it.onNewMessage() }
val channel = getOrCreateChannel(user)
if (msg !in channel) {
messages = messages + Pair(user, channel + msg)
liveMessages.invalidateData()
updateSubscribers { it.onNewMessage() }
}
}
data class RelayInfo (
val url: String,
var lastEvent: Long,
var counter: Long
)
fun addRelay(relay: Relay, eventTime: Long) {
val here = relaysBeingUsed[relay.url]
if (here == null) {
relaysBeingUsed = relaysBeingUsed + Pair(relay.url, RelayInfo(relay.url, eventTime, 1))
} else {
if (eventTime > here.lastEvent) {
here.lastEvent = eventTime
}
here.counter++
}
updateSubscribers { it.onNewRelayInfo() }
liveRelayInfo.invalidateData()
}
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
val toBeAdded = synchronized(follows) {
newFollows - follows
}
val toBeRemoved = synchronized(follows) {
follows - newFollows
}
val toBeAdded = newFollows - follows
val toBeRemoved = follows - newFollows
toBeAdded.forEach {
follow(it, updateAt)
}
@@ -122,35 +185,33 @@ class User(val pubkey: ByteArray) {
it.onRelayChange()
}
}
liveRelays.invalidateData()
}
fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) {
info = newUserInfo
updatedMetadataAt = updateAt
if (info.picture?.startsWith( "http://") == true) {
info.picture = info.picture?.replaceFirst("http://", "https://")
}
if (info.banner?.startsWith( "http://") == true) {
info.banner = info.banner?.replaceFirst("http://", "https://")
}
invalidateData()
liveMetadata.invalidateData()
}
fun isFollowing(user: User): Boolean {
return synchronized(follows) {
follows.contains(user)
}
return follows.contains(user)
}
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = messages[user] ?: return false
return synchronized(messagesToUser) {
messagesToUser.firstOrNull { this == it.author } != null
}
return messagesToUser.firstOrNull { this == it.author } != null
}
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
return reports.firstOrNull {
it.author == loggedIn
&& it.event is ReportEvent
&& (it.event as ReportEvent).reportType.contains(type)
} != null
}
// Model Observers
@@ -167,8 +228,11 @@ class User(val pubkey: ByteArray) {
abstract class Listener {
open fun onRelayChange() = Unit
open fun onFollowsChange() = Unit
open fun onNewPosts() = Unit
open fun onNewTaggedPosts() = Unit
open fun onNewNotes() = Unit
open fun onNewMessage() = Unit
open fun onNewRelayInfo() = Unit
open fun onNewReports() = Unit
}
// Refreshes observers in batches.
@@ -178,7 +242,7 @@ class User(val pubkey: ByteArray) {
if (modelHandlerWaiting) return
modelHandlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Main)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
listeners.forEach {
@@ -189,22 +253,12 @@ class User(val pubkey: ByteArray) {
}
// UI Observers line up here.
val live: UserLiveData = UserLiveData(this)
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
}
}
val liveFollows: UserLiveData = UserLiveData(this)
val liveReports: UserLiveData = UserLiveData(this)
val liveMessages: UserLiveData = UserLiveData(this)
val liveRelays: UserLiveData = UserLiveData(this)
val liveRelayInfo: UserLiveData = UserLiveData(this)
val liveMetadata: UserLiveData = UserLiveData(this)
}
class UserMetadata {
@@ -233,7 +287,23 @@ class UserMetadata {
}
class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
fun refresh() {
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
}
private fun refresh() {
postValue(UserState(user))
}
@@ -4,7 +4,12 @@ import java.util.UUID
import nostr.postr.JsonFilter
data class Channel (
val id: String = UUID.randomUUID().toString().substring(0,4)
val id: String = UUID.randomUUID().toString().substring(0,4),
val onEOSE: ((Long) -> Unit)? = null
) {
var filter: List<JsonFilter>? = null // Inactive when null
fun updateEOSE(l: Long) {
onEOSE?.let { it(l) }
}
}
@@ -6,20 +6,22 @@ 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://brb.io", read = true, write = true),
Relay("wss://relay.snort.social", 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://eden.nostr.land", read = true, write = true),
//Relay("wss://nostr-2.zebedee.cloud", read = true, write = true),
Relay("wss://nostr.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),
Relay("wss://relay.nostr.ch", read = true, write = true)
Relay("wss://relay.nostr.ch", read = true, write = true),
Relay("wss://no.str.cr", read = true, write = true),
Relay("wss://nos.lol", read = true, write = true)
)
}
@@ -36,13 +36,14 @@ class Nip19 {
}
}
} catch (e: Throwable) {
println("Trying to Decode NIP19: ${uri}")
e.printStackTrace()
}
return null
}
fun parseTLV(data: ByteArray): Map<Byte, MutableList<ByteArray>> {
fun parseTLV(data: ByteArray): Map<Byte, List<ByteArray>> {
var result = mutableMapOf<Byte, MutableList<ByteArray>>()
var rest = data
while (rest.isNotEmpty()) {
@@ -4,6 +4,7 @@ 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.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
@@ -17,7 +18,7 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
return JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 5
limit = 1
)
}
@@ -25,34 +26,40 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
return JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 5
limit = 1
)
}
val accountMetadataChannel = requestNewChannel()
val accountContactListChannel = requestNewChannel()
fun createAccountReportsFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(ReportEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
)
}
fun createNotificationFilter() = JsonFilter(
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 200
)
val accountChannel = requestNewChannel()
override fun feed(): List<Note> {
val user = account.userProfile()
val follows = user.follows
val followKeys = synchronized(follows) {
follows.map { it.pubkeyHex }
}
val allowSet = followKeys.plus(user.pubkeyHex).toSet()
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author in user.follows }
.sortedBy { it.event?.createdAt }
.reversed()
}
override fun updateChannelFilters() {
// gets everthing about the user logged in
val newAccountMetadataFilter = createAccountMetadataFilter()
accountMetadataChannel.filter = listOf(newAccountMetadataFilter).ifEmpty { null }
val newAccountContactListEvent = createAccountContactListFilter()
accountContactListChannel.filter = listOf(newAccountContactListEvent).ifEmpty { null }
accountChannel.filter = listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createNotificationFilter(),
createAccountReportsFilter()
).ifEmpty { null }
}
}
@@ -11,7 +11,7 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
var channel: com.vitorpamplona.amethyst.model.Channel? = null
fun loadMessagesBetween(channelId: String) {
channel = LocalCache.channels[channelId]
channel = LocalCache.getOrCreateChannel(channelId)
resetFilters()
}
@@ -20,7 +20,7 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
return JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours
limit = 200
)
}
return null
@@ -16,34 +16,44 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
withUser = LocalCache.users[userId]
}
fun createMessagesToMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = withUser?.let { listOf(it.pubkeyHex) },
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
fun createMessagesToMeFilter(): JsonFilter? {
val myPeer = withUser
return if (myPeer != null) {
JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(myPeer.pubkeyHex) ,
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
} else {
null
}
}
fun createMessagesFromMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
tags = withUser?.let { mapOf("p" to listOf(it.pubkeyHex)) }
)
fun createMessagesFromMeFilter(): JsonFilter? {
val myPeer = withUser
return if (myPeer != null) {
JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to listOf(myPeer.pubkeyHex))
)
} else {
null
}
}
val incomingChannel = requestNewChannel()
val outgoingChannel = requestNewChannel()
val inandoutChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages[withUser] ?: return emptyList()
val filteredMessages = synchronized(messages) {
messages.filter { account.isAcceptable(it) }
}
return filteredMessages.sortedBy { it.event?.createdAt }.reversed()
return messages.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.reversed()
}
override fun updateChannelFilters() {
incomingChannel.filter = listOf(createMessagesToMeFilter()).ifEmpty { null }
outgoingChannel.filter = listOf(createMessagesFromMeFilter()).ifEmpty { null }
inandoutChannel.filter = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
}
}
@@ -52,29 +52,15 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
}
}
val incomingChannel = requestNewChannel()
val outgoingChannel = requestNewChannel()
val myChannelsChannel = requestNewChannel()
val myChannelsInfoChannel = requestNewChannel()
val myChannelsMessagesChannel = requestNewChannel()
val myChannelsCreatedbyMeChannel = requestNewChannel()
val chatroomListChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages
val messagingWith = messages.keys().toList().filter { account.isAcceptable(it) }
val messagingWith = messages.keys.filter { account.isAcceptable(it) }
val privateMessages = messagingWith.mapNotNull {
val conversation = messages[it]
if (conversation != null) {
synchronized(conversation) {
conversation.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
}
} else {
null
}
messages[it]?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null }
}
val publicChannels = account.followingChannels().map {
@@ -85,12 +71,16 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
}
override fun updateChannelFilters() {
incomingChannel.filter = listOf(createMessagesToMeFilter()).ifEmpty { null }
outgoingChannel.filter = listOf(createMessagesFromMeFilter()).ifEmpty { null }
myChannelsChannel.filter = listOf(createMyChannelsFilter()).ifEmpty { null }
myChannelsInfoChannel.filter = createLastChannelInfoFilter().ifEmpty { null }
myChannelsMessagesChannel.filter = createLastMessageOfEachChannelFilter().ifEmpty { null }
val list = listOf(
createMessagesToMeFilter(),
createMessagesFromMeFilter(),
createMyChannelsFilter()
)
//myChannelsCreatedbyMeChannel.filter = listOf(createChannelsCreatedbyMeFilter()).ifEmpty { null }
chatroomListChannel.filter = listOfNotNull(
list,
createLastChannelInfoFilter(),
createLastMessageOfEachChannelFilter()
).flatten().ifEmpty { null }
}
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
@@ -11,10 +12,12 @@ import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
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 java.util.Date
import java.util.UUID
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@@ -32,10 +35,8 @@ import nostr.postr.events.RecommendRelayEvent
import nostr.postr.events.TextNoteEvent
abstract class NostrDataSource<T>(val debugName: String) {
private val channels = Collections.synchronizedSet(mutableSetOf<Channel>())
private val channelIds = Collections.synchronizedSet(mutableSetOf<String>())
private val eventCounter = mutableMapOf<String, Int>()
private var channels = mapOf<String, Channel>()
private var eventCounter = mapOf<String, Int>()
fun printCounter() {
eventCounter.forEach {
@@ -45,27 +46,32 @@ abstract class NostrDataSource<T>(val debugName: String) {
private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
if (subscriptionId in channelIds) {
if (subscriptionId in channels.keys) {
val key = "${debugName} ${subscriptionId} ${event.kind}"
if (eventCounter.contains(key)) {
eventCounter.put(key, eventCounter.get(key)!! + 1)
val keyValue = eventCounter.get(key)
if (keyValue != null) {
eventCounter = eventCounter + Pair(key, keyValue + 1)
} else {
eventCounter.put(key, 1)
eventCounter = eventCounter + Pair(key, 1)
}
try {
when (event) {
is MetadataEvent -> LocalCache.consume(event)
is TextNoteEvent -> LocalCache.consume(event)
is TextNoteEvent -> LocalCache.consume(event, relay)
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))
RepostEvent.kind -> {
val repostEvent = RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)
repostEvent.containedPost?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(repostEvent)
}
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ReportEvent.kind -> LocalCache.consume(ReportEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelCreateEvent.kind -> LocalCache.consume(ChannelCreateEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMetadataEvent.kind -> LocalCache.consume(ChannelMetadataEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
@@ -85,7 +91,7 @@ abstract class NostrDataSource<T>(val debugName: String) {
//Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
}
override fun onRelayStateChange(type: Relay.Type, relay: Relay) {
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
//Log.d("RELAY", "Relay ${relay.url} ${when (type) {
// Relay.Type.CONNECT -> "connected."
// Relay.Type.DISCONNECT -> "disconnected."
@@ -93,11 +99,10 @@ abstract class NostrDataSource<T>(val debugName: String) {
// 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()
}*/
if (type == Relay.Type.EOSE && channel != null) {
// updates a per subscripton since date
channels[channel]?.updateEOSE(Date().time / 1000)
}
}
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
@@ -114,7 +119,7 @@ abstract class NostrDataSource<T>(val debugName: String) {
}
open fun stop() {
channels.forEach { channel ->
channels.values.forEach { channel ->
if (channel.filter != null) // if it is active, close
Client.close(channel.id)
}
@@ -140,17 +145,15 @@ abstract class NostrDataSource<T>(val debugName: String) {
}
}
fun requestNewChannel(): Channel {
val newChannel = Channel(debugName+UUID.randomUUID().toString().substring(0,4))
channels.add(newChannel)
channelIds.add(newChannel.id)
fun requestNewChannel(onEOSE: ((Long) -> Unit)? = null): Channel {
val newChannel = Channel(UUID.randomUUID().toString().substring(0,4), onEOSE)
channels = channels + Pair(newChannel.id, newChannel)
return newChannel
}
fun dismissChannel(channel: Channel) {
Client.close(channel.id)
channels.remove(channel)
channelIds.remove(channel.id)
channels = channels.minus(channel.id)
}
var handlerWaiting = false
@@ -176,14 +179,14 @@ abstract class NostrDataSource<T>(val debugName: String) {
fun resetFiltersSuspend() {
// saves the channels that are currently active
val activeChannels = channels.filter { it.filter != null }
val activeChannels = channels.values.filter { it.filter != null }
// saves the current content to only update if it changes
val currentFilter = activeChannels.associate { it.id to it.filter!!.joinToString("|") { it.toJson() } }
updateChannelFilters()
// Makes sure to only send an updated filter when it actually changes.
channels.forEach { channel ->
channels.values.forEach { channel ->
val channelsNewFilter = channel.filter
if (channel in activeChannels) {
@@ -4,11 +4,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
@@ -35,12 +31,10 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
}
fun createFollowAccountsFilter(): JsonFilter {
val follows = account.userProfile().follows ?: emptySet()
val follows = account.userProfile().follows
val followKeys = synchronized(follows) {
follows.map {
it.pubkey.toHex().substring(0, 6)
}
val followKeys = follows.map {
it.pubkey.toHex().substring(0, 6)
}
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
@@ -48,7 +42,7 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
authors = followSet,
limit = 200
limit = 400
)
}
@@ -73,15 +67,8 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
override fun feed(): List<Note> {
val user = account.userProfile()
val follows = user.follows
val followKeys = synchronized(follows) {
follows.map { it.pubkeyHex }
}
val allowSet = followKeys.plus(user.pubkeyHex).toSet()
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author in user.follows }
.filter { account.isAcceptable(it) }
.sortedBy { it.event?.createdAt }
.reversed()
@@ -3,28 +3,26 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import nostr.postr.JsonFilter
object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
lateinit var account: Account
fun createNotificationFilter() = JsonFilter(
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 100
)
val notificationChannel = requestNewChannel()
override fun feed(): List<Note> {
val set = account.userProfile().taggedPosts
val filtered = synchronized(set) {
set.filter { it.event != null }.filter { account.isAcceptable(it) }
}
return filtered.sortedBy { it.event?.createdAt }.reversed()
return account.userProfile().taggedPosts
.filter { it.event != null }
.filter { account.isAcceptable(it) }
.filter {
it.event !is ChannelCreateEvent
&& it.event !is ChannelMetadataEvent
}
.sortedBy { it.event?.createdAt }
.reversed()
}
override fun updateChannelFilters() {
notificationChannel.filter = listOf(createNotificationFilter()).ifEmpty { null }
}
override fun updateChannelFilters() {}
}
@@ -46,8 +46,7 @@ object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed")
)
}
val repliesAndReactionsChannel = requestNewChannel()
val loadEventsChannel = requestNewChannel()
val singleChannelChannel = requestNewChannel()
override fun feed(): List<Note> {
return emptyList()
@@ -57,8 +56,7 @@ object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed")
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
repliesAndReactionsChannel.filter = listOfNotNull(reactions).ifEmpty { null }
loadEventsChannel.filter = listOfNotNull(missing).ifEmpty { null }
singleChannelChannel.filter = listOfNotNull(reactions, missing).ifEmpty { null }
}
fun add(eventId: String) {
@@ -6,31 +6,41 @@ import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
import java.util.Date
import nostr.postr.JsonFilter
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
private var eventsToWatch = setOf<String>()
private fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it }
private fun createRepliesAndReactionsFilter(): List<JsonFilter>? {
val reactionsToWatch = eventsToWatch.map { LocalCache.getOrCreateNote(it) }
if (reactionsToWatch.isEmpty()) {
return null
}
// downloads all the reactions to a given event.
return JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind
),
tags = mapOf("e" to reactionsToWatch)
)
val now = Date().time / 1000
return reactionsToWatch.filter {
val lastTime = it.lastReactionsDownloadTime;
lastTime == null || lastTime < (now - 10)
}.map {
JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
)
}
}
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
fun createLoadEventsIfNotLoadedFilter(): List<JsonFilter>? {
val directEventsToLoad = eventsToWatch
.map { LocalCache.getOrCreateNote(it) }
.filter { it.event == null }
@@ -50,17 +60,23 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
}
// downloads linked events to this event.
return JsonFilter(
return listOf(JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
),
ids = interestedEvents.toList()
)
))
}
val repliesAndReactionsChannel = requestNewChannel()
val loadEventsChannel = requestNewChannel()
val singleEventChannel = requestNewChannel() { time ->
eventsToWatch.forEach {
LocalCache.getOrCreateNote(it).lastReactionsDownloadTime = time
}
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
override fun feed(): List<Note> {
return synchronized(eventsToWatch) {
@@ -74,8 +90,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
repliesAndReactionsChannel.filter = listOfNotNull(reactions).ifEmpty { null }
loadEventsChannel.filter = listOfNotNull(missing).ifEmpty { null }
singleEventChannel.filter = listOfNotNull(reactions, missing).flatten().ifEmpty { null }
}
fun add(eventId: String) {
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ReportEvent
import java.util.Collections
import nostr.postr.JsonFilter
import nostr.postr.events.MetadataEvent
@@ -13,7 +14,7 @@ object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
fun createUserFilter(): List<JsonFilter>? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.map {
return usersToWatch.filter { LocalCache.getOrCreateUser(it).latestMetadata == null }.map {
JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it),
@@ -22,7 +23,22 @@ object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
}
}
val userChannel = requestNewChannel()
fun createUserReportFilter(): List<JsonFilter>? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.map {
JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it))
)
}
}
val userChannel = requestNewChannel(){
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
override fun feed(): List<User> {
return synchronized(usersToWatch) {
@@ -33,7 +49,7 @@ object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
}
override fun updateChannelFilters() {
userChannel.filter = createUserFilter()
userChannel.filter = listOfNotNull(createUserFilter(), createUserReportFilter()).flatten()
}
fun add(userId: String) {
@@ -9,28 +9,23 @@ import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
val eventsToWatch = Collections.synchronizedList(mutableListOf<String>())
private var eventsToWatch = setOf<String>()
fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it }
if (reactionsToWatch.isEmpty()) {
if (eventsToWatch.isEmpty()) {
return null
}
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
tags = mapOf("e" to reactionsToWatch)
tags = mapOf("e" to eventsToWatch.toList())
)
}
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
val nodes = synchronized(eventsToWatch) {
eventsToWatch.map { LocalCache.notes[it] }
}
val nodes = eventsToWatch.map { LocalCache.getOrCreateNote(it) }
val eventsToLoad = nodes
.filterNotNull()
.filter { it.event == null }
.map { it.idHex.substring(0, 8) }
@@ -43,20 +38,19 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
)
}
val repliesAndReactionsChannel = requestNewChannel()
val loadEventsChannel = requestNewChannel()
override fun feed(): List<Note> {
return synchronized(eventsToWatch) {
eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
}
// Currently orders by date of each event, descending, at each level of the reply stack
val order = compareByDescending<Note> { it.replyLevelSignature() }
return eventsToWatch.map {
LocalCache.getOrCreateNote(it)
}.sortedWith(order)
}
override fun updateChannelFilters() {
repliesAndReactionsChannel.filter = listOfNotNull(createRepliesAndReactionsFilter()).ifEmpty { null }
loadEventsChannel.filter = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
loadEventsChannel.filter = listOfNotNull(createLoadEventsIfNotLoadedFilter(), createRepliesAndReactionsFilter()).ifEmpty { null }
}
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
@@ -86,9 +80,9 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
}
fun loadThread(noteId: String) {
val note = LocalCache.notes[noteId]
val note = LocalCache.getOrCreateNote(noteId)
if (note != null) {
if (note.event != null) {
val thread = mutableListOf<Note>()
val threadSet = mutableSetOf<Note>()
@@ -96,17 +90,12 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
loadDown(threadRoot, thread, threadSet)
// Currently orders by date of each event, descending, at each level of the reply stack
val order = compareByDescending<Note> { it.replyLevelSignature() }
eventsToWatch.clear()
eventsToWatch.addAll(thread.sortedWith(order).map { it.idHex })
eventsToWatch = thread.map { it.idHex }.toSet()
} else {
eventsToWatch.clear()
eventsToWatch.add(noteId)
eventsToWatch = setOf(noteId)
}
resetFilters()
invalidateFilters()
}
fun loadDown(note: Note, thread: MutableList<Note>, threadSet: MutableSet<Note>) {
@@ -114,9 +103,7 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
thread.add(note)
threadSet.add(note)
synchronized(note.replies) {
note.replies.toList()
}.forEach {
note.replies.forEach {
loadDown(it, thread, threadSet)
}
}
@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toByteArray
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
@@ -14,7 +15,7 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
var user: User? = null
fun loadUserProfile(userId: String) {
user = LocalCache.getOrCreateUser(userId.toByteArray())
user = LocalCache.getOrCreateUser(userId)
resetFilters()
}
@@ -34,19 +35,35 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
)
}
fun createFollowFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 1
)
}
fun createFollowersFilter() = JsonFilter(
kinds = listOf(ContactListEvent.kind),
tags = mapOf("p" to listOf(user!!.pubkeyHex))
)
val userInfoChannel = requestNewChannel()
val notesChannel = requestNewChannel()
override fun feed(): List<Note> {
val notes = user?.notes ?: return emptyList()
val sortedNotes = synchronized(notes) {
notes.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }
}
return sortedNotes.reversed()
return user?.notes
?.filter { account.isAcceptable(it) }
?.sortedBy { it.event?.createdAt }
?.reversed()
?: emptyList()
}
override fun updateChannelFilters() {
userInfoChannel.filter = listOf(createUserInfoFilter()).ifEmpty { null }
notesChannel.filter = listOf(createUserPostsFilter()).ifEmpty { null }
userInfoChannel.filter = listOf(
createUserInfoFilter(),
createUserPostsFilter(),
createFollowFilter(),
createFollowersFilter()
).ifEmpty { null }
}
}
@@ -15,22 +15,9 @@ object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFo
resetFilters()
}
fun createFollowersFilter() = JsonFilter(
kinds = listOf(ContactListEvent.kind),
tags = mapOf("p" to listOf(user!!.pubkeyHex))
)
val followerChannel = requestNewChannel()
override fun feed(): List<User> {
val followers = user?.followers ?: emptyList()
return synchronized(followers) {
followers.filter { account.isAcceptable(it) }.toList()
}
return user?.followers?.filter { account.isAcceptable(it) } ?: emptyList()
}
override fun updateChannelFilters() {
followerChannel.filter = listOf(createFollowersFilter()).ifEmpty { null }
}
override fun updateChannelFilters() {}
}
@@ -15,25 +15,9 @@ object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFoll
resetFilters()
}
fun createFollowFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 1
)
}
val followChannel = requestNewChannel()
override fun feed(): List<User> {
val follows = user?.follows ?: emptyList()
return synchronized(follows) {
follows.filter { account.isAcceptable(it) }.toList()
}
return user?.follows?.filter { account.isAcceptable(it) } ?: emptyList()
}
override fun updateChannelFilters() {
followChannel.filter = listOf(createFollowFilter()).ifEmpty { null }
}
override fun updateChannelFilters() {}
}
@@ -0,0 +1,66 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.ui.text.toUpperCase
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
// NIP 56 event.
class ReportEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val reportType: List<ReportType>
@Transient val reportedPost: List<String>
@Transient val reportedAuthor: List<String>
init {
reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }
reportedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
reportedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
companion object {
const val kind = 1984
fun create(reportedPost: Event, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
val reportTypeTag = listOf("report", type.name.toLowerCase())
val reportPostTag = listOf("e", reportedPost.id.toHex())
val reportAuthorTag = listOf("p", reportedPost.pubKey.toHex())
val pubKey = Utils.pubkeyCreate(privateKey)
val tags:List<List<String>> = listOf(reportTypeTag, reportPostTag, reportAuthorTag)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
}
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
val reportTypeTag = listOf("report", type.name.toLowerCase())
val reportAuthorTag = listOf("p", reportedUser)
val pubKey = Utils.pubkeyCreate(privateKey)
val tags:List<List<String>> = listOf(reportTypeTag, reportAuthorTag)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
}
}
enum class ReportType() {
EXPLICIT,
ILLEGAL,
SPAM,
IMPERSONATION
}
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.service.relays.Client
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
@@ -16,17 +17,24 @@ class RepostEvent (
@Transient val boostedPost: List<String>
@Transient val originalAuthor: List<String>
@Transient val containedPost: Event?
init {
boostedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
originalAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
containedPost = try {
fromJson(content, Client.lenient)
} catch (e: Exception) {
null
}
}
companion object {
const val kind = 6
fun create(boostedPost: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RepostEvent {
val content = ""
val content = boostedPost.toJson()
val replyToPost = listOf("e", boostedPost.id.toHex())
val replyToAuthor = listOf("p", boostedPost.pubKey.toHex())
@@ -25,7 +25,7 @@ object Client: RelayPool.Listener {
var lenient: Boolean = false
private var listeners = setOf<Listener>()
private var relays = Constants.defaultRelays
private val subscriptions = mutableMapOf<String, List<JsonFilter>>()
private var subscriptions = mapOf<String, List<JsonFilter>>()
fun connect(relays: Array<Relay> = Constants.defaultRelays) {
RelayPool.register(this)
@@ -38,7 +38,7 @@ object Client: RelayPool.Listener {
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<JsonFilter> = listOf(JsonFilter())
) {
subscriptions[subscriptionId] = filters
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilter(subscriptionId)
}
@@ -46,7 +46,7 @@ object Client: RelayPool.Listener {
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<JsonFilter> = listOf(JsonFilter())
) {
subscriptions[subscriptionId] = filters
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilterOnlyIfDisconnected()
}
@@ -72,8 +72,8 @@ object Client: RelayPool.Listener {
listeners.forEach { it.onError(error, subscriptionId, relay) }
}
override fun onRelayStateChange(type: Relay.Type, relay: Relay) {
listeners.forEach { it.onRelayStateChange(type, relay) }
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
}
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
@@ -89,9 +89,7 @@ object Client: RelayPool.Listener {
}
fun allSubscriptions(): List<String> {
return synchronized(subscriptions) {
subscriptions.keys.toList()
}
return subscriptions.keys.toList()
}
fun getSubscriptionFilters(subId: String): List<JsonFilter> {
@@ -112,7 +110,7 @@ object Client: RelayPool.Listener {
/**
* Connected to or disconnected from a relay
*/
open fun onRelayStateChange(type: Relay.Type, relay: Relay) = Unit
open fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) = Unit
/**
* When an relay saves or rejects a new event.
@@ -2,11 +2,9 @@ package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.google.gson.JsonElement
import com.vitorpamplona.amethyst.model.LocalCache
import java.util.Date
import nostr.postr.events.ContactListEvent
import java.util.concurrent.TimeUnit
import nostr.postr.events.Event
import nostr.postr.toNpub
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -18,13 +16,22 @@ class Relay(
var read: Boolean = true,
var write: Boolean = true
) {
private val httpClient = OkHttpClient()
private val httpClient = OkHttpClient.Builder()
.connectTimeout(100, TimeUnit.SECONDS)
.readTimeout(100, TimeUnit.SECONDS)
.callTimeout(100, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build();
private var listeners = setOf<Listener>()
private var socket: WebSocket? = null
private var isReady: Boolean = false
var eventDownloadCounter = 0
var eventUploadCounter = 0
var errorCounter = 0
var ping: Long? = null
var closingTime = 0L
@@ -40,82 +47,106 @@ class Relay(
return socket != null
}
@Synchronized
fun requestAndWatch() {
val request = Request.Builder().url(url.trim()).build()
val listener = object : WebSocketListener() {
if (socket != null) return
override fun onOpen(webSocket: WebSocket, response: Response) {
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
try {
val request = Request.Builder().url(url.trim()).build()
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
isReady = true
ping = response.receivedResponseAtMillis - response.sentRequestAtMillis
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT, null) }
}
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" -> {
eventDownloadCounter++
val event = Event.fromJson(msg[2], Client.lenient)
listeners.forEach { it.onEvent(this@Relay, channel, event) }
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" -> {
//Log.w("Relay", "Relay onEVENT $url, $channel")
eventDownloadCounter++
val event = Event.fromJson(msg[2], Client.lenient)
listeners.forEach { it.onEvent(this@Relay, channel, event) }
}
"EOSE" -> listeners.forEach {
//Log.w("Relay", "Relay onEOSE $url, $channel")
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
}
"NOTICE" -> listeners.forEach {
//Log.w("Relay", "Relay onNotice $url, $channel")
// "channel" being the second string in the string array ...
it.onError(this@Relay, channel, Error("Relay sent notice: $channel"))
}
"OK" -> listeners.forEach {
//Log.w("Relay", "Relay onOK $url, $channel")
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
}
else -> listeners.forEach {
//Log.w("Relay", "Relay something else $url, $channel")
it.onError(
this@Relay,
channel,
Error("Unknown type $type on channel $channel. Msg was $text")
)
}
}
"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 {
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
}
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")) }
}
}
} catch (t: Throwable) {
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
listeners.forEach { it.onRelayStateChange(
this@Relay,
Type.DISCONNECTING,
null
) }
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
socket = null
isReady = false
closingTime = Date().time / 1000
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT, null) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
errorCounter++
socket?.close(1000, "Normal close")
// Failures disconnect the relay.
socket = null
isReady = false
closingTime = Date().time / 1000
Log.w("Relay", "Relay onFailure $url, ${response?.message} ${response}")
t.printStackTrace()
text.chunked(2000) { chunked ->
listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) }
listeners.forEach {
it.onError(this@Relay, "", Error("WebSocket Failure. Response: ${response}. Exception: ${t.message}", t))
}
}
}
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
closingTime = Date().time / 1000
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
errorCounter++
socket?.close(1000, "Normal close")
// Failures disconnect the relay.
socket = null
closingTime = Date().time / 1000
Log.w("Relay", "Relay onFailure ${url}, ${response?.message}")
//t.printStackTrace()
listeners.forEach {
it.onError(this@Relay, "", Error("WebSocket Failure. Response: ${response}. Exception: ${t.message}", t))
}
}
socket = httpClient.newWebSocket(request, listener)
} catch (e: Exception) {
closingTime = Date().time / 1000
Log.e("Relay", "Relay Invalid $url")
e.printStackTrace()
}
socket = httpClient.newWebSocket(request, listener)
}
fun disconnect() {
@@ -123,6 +154,7 @@ class Relay(
closingTime = Date().time / 1000
socket?.close(1000, "Normal close")
socket = null
isReady = false
}
fun sendFilter(requestId: String) {
@@ -133,12 +165,14 @@ class Relay(
requestAndWatch()
}
} else {
val filters = Client.getSubscriptionFilters(requestId)
if (filters.isNotEmpty()) {
val request =
"""["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]"""
//println("FILTERSSENT ${url} " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""")
socket?.send(request)
if (isReady) {
val filters = Client.getSubscriptionFilters(requestId)
if (filters.isNotEmpty()) {
val request =
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.toJson() }}]"""
//println("FILTERSSENT ${url} " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""")
socket?.send(request)
}
}
}
}
@@ -146,6 +180,7 @@ class Relay(
fun sendFilterOnlyIfDisconnected() {
if (socket == null) {
//println("sendfilter Only if Disconnected ${url} ")
requestAndWatch()
}
}
@@ -186,6 +221,6 @@ class Relay(
*
* @param type is 0 for disconnect and 1 for connect
*/
fun onRelayStateChange(relay: Relay, type: Type)
fun onRelayStateChange(relay: Relay, type: Type, channel: String?)
}
}
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.service.relays
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.Constants
import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -88,7 +87,7 @@ object RelayPool: Relay.Listener {
fun onError(error: Error, subscriptionId: String, relay: Relay)
fun onRelayStateChange(type: Relay.Type, relay: Relay)
fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?)
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
}
@@ -103,8 +102,8 @@ object RelayPool: Relay.Listener {
refreshObservers()
}
override fun onRelayStateChange(relay: Relay, type: Relay.Type) {
listeners.forEach { it.onRelayStateChange(type, relay) }
override fun onRelayStateChange(relay: Relay, type: Relay.Type, channel: String?) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
refreshObservers()
}
@@ -43,9 +43,13 @@ object ImageUploader {
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
val tree = jacksonObjectMapper().readTree(response.body!!.string())
val url = tree.get("data").get("link").asText()
onSuccess(url)
val body = response.body
if (body != null) {
val tree = jacksonObjectMapper().readTree(body.string())
val url = tree?.get("data")?.get("link")?.asText()
if (url != null)
onSuccess(url)
}
}
}
@@ -16,6 +16,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import nostr.postr.toNpub
@@ -25,7 +26,7 @@ class NewPostViewModel: ViewModel() {
private var originalNote: Note? = null
var mentions by mutableStateOf<List<User>?>(null)
var replyTos by mutableStateOf<MutableList<Note>?>(null)
var replyTos by mutableStateOf<List<Note>?>(null)
var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null)
@@ -36,7 +37,7 @@ class NewPostViewModel: ViewModel() {
fun load(account: Account, replyingTo: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
this.replyTos = (replyNote.replyTo ?: mutableListOf()).plus(replyNote).toMutableList()
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
replyNote.author?.let { replyUser ->
val currentMentions = replyNote.mentions ?: emptyList()
if (currentMentions.contains(replyUser)) {
@@ -78,7 +79,7 @@ class NewPostViewModel: ViewModel() {
val restOfWord = word.substring(64)
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key)
val user = LocalCache.getOrCreateUser(key.toHexKey())
val index = addUserToMentionsIfNotInAndReturnIndex(user)
@@ -60,7 +60,7 @@ import java.lang.Math.round
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewRelayListView(onClose: () -> Unit, account: Account) {
fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = "") {
val postViewModel: NewRelayListViewModel = viewModel()
val feedState by postViewModel.relays.collectAsState()
@@ -131,7 +131,7 @@ fun NewRelayListView(onClose: () -> Unit, account: Account) {
Spacer(modifier = Modifier.height(10.dp))
EditableServerConfig() {
EditableServerConfig(relayToAdd) {
postViewModel.addRelay(it)
}
}
@@ -295,8 +295,8 @@ fun ServerConfig(
}
@Composable
fun EditableServerConfig(onNewRelay: (NewRelayListViewModel.Relay) -> Unit) {
var url by remember { mutableStateOf<String>("") }
fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.Relay) -> Unit) {
var url by remember { mutableStateOf<String>(relayToAdd) }
var read by remember { mutableStateOf(true) }
var write by remember { mutableStateOf(true) }
@@ -65,7 +65,7 @@ class NewRelayListViewModel: ViewModel() {
}
fun addRelay(relay: Relay) {
if (relays.value.filter { it.url == relay.url }.isNotEmpty()) return
if (relays.value.any { it.url == relay.url }) return
_relays.update {
it.plus(relay)
@@ -12,6 +12,7 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import kotlin.math.roundToInt
data class RangesChanges(val original: TextRange, val modified: TextRange)
@@ -43,7 +44,7 @@ fun buildAnnotatedStringWithUrlHighlighting(text: AnnotatedString, color: Color)
val endIndex = startIndex + keyB32.length
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key)
val user = LocalCache.getOrCreateUser(key.toHexKey())
val newWord = "@${user.toBestDisplayName()}"
val startNew = builderAfter.toString().length
@@ -0,0 +1,64 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.text.AnnotatedString
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import nostr.postr.toNpub
@Composable
fun ClickableRoute(
nip19: Nip19.Return,
navController: NavController
) {
if (nip19.type == Nip19.Type.USER) {
val userBase = LocalCache.getOrCreateUser(nip19.hex)
val userState by userBase.liveMetadata.observeAsState()
val user = userState?.user ?: return
val route = "User/${nip19.hex}"
val text = user.toBestDisplayName()
ClickableText(
text = AnnotatedString("@${text} "),
onClick = { navController.navigate(route) },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else {
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
val noteState by noteBase.live.observeAsState()
val note = noteState?.note ?: return
if (note.event is ChannelCreateEvent) {
ClickableText(
text = AnnotatedString("@${note.idDisplayNote} "),
onClick = { navController.navigate("Channel/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else if (note.channel != null) {
ClickableText(
text = AnnotatedString("@${note.channel?.toBestDisplayName()} "),
onClick = { navController.navigate("Channel/${note.channel?.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else {
ClickableText(
text = AnnotatedString("@${note.idDisplayNote} "),
onClick = { navController.navigate("Note/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}
}
}
@@ -15,7 +15,7 @@ fun ClickableUserTag(
user: User,
navController: NavController
) {
val innerUserState by user.live.observeAsState()
val innerUserState by user.liveMetadata.observeAsState()
ClickableText(
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()} "),
onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") },
@@ -1,27 +1,47 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.res.Resources
import android.util.LruCache
import android.util.Patterns
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
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.text.TextStyle
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.ConfigurationCompat
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.Tasks
import com.google.mlkit.nl.languageid.LanguageIdentification
import com.google.mlkit.nl.translate.TranslateLanguage
import com.google.mlkit.nl.translate.Translation
import com.google.mlkit.nl.translate.Translator
import com.google.mlkit.nl.translate.TranslatorOptions
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.Locale
import java.util.regex.Pattern
import nostr.postr.toNpub
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$")
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm)$")
@@ -45,9 +65,25 @@ fun isValidURL(url: String?): Boolean {
@Composable
fun RichTextViewer(content: String, tags: List<List<String>>?, navController: NavController) {
val translatedTextState = remember {
mutableStateOf(ResultOrError(content, null, null, null))
}
var showOriginal by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
LanguageTranslatorService.autoTranslate(content).addOnCompleteListener { task ->
if (task.isSuccessful) {
translatedTextState.value = task.result
}
}
}
val text = if (showOriginal) content else translatedTextState.value.result
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 ->
text?.split('\n')?.forEach { paragraph ->
FlowRow() {
paragraph.split(' ').forEach { word: String ->
@@ -72,6 +108,8 @@ fun RichTextViewer(content: String, tags: List<List<String>>?, navController: Na
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
Text(
text = "$word ",
@@ -80,11 +118,74 @@ fun RichTextViewer(content: String, tags: List<List<String>>?, navController: Na
}
}
}
}
val target = translatedTextState.value.targetLang
val source = translatedTextState.value.sourceLang
if (source != null && target != null) {
if (source != target) {
Row() {
Text(
text = "Auto-translated from ",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
ClickableText(
text = AnnotatedString("${Locale(source).displayName}"),
onClick = { showOriginal = true },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
)
Text(
text = " to ",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
ClickableText(
text = AnnotatedString("${Locale(target).displayName}"),
onClick = { showOriginal = false },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
)
}
}
}
}
}
fun isBechLink(word: String): Boolean {
return word.startsWith("nostr:", true)
|| word.startsWith("npub1", true)
|| word.startsWith("note1", true)
|| word.startsWith("nprofile1", true)
|| word.startsWith("nevent1", true)
|| word.startsWith("@npub1", true)
|| word.startsWith("@note1", true)
|| word.startsWith("@nprofile1", true)
|| word.startsWith("@nevent1", true)
}
@Composable
fun BechLink(word: String, navController: NavController) {
val uri = if (word.startsWith("nostr", true)) {
word
} else if (word.startsWith("@")) {
word.replaceFirst("@", "nostr:")
} else {
"nostr:${word}"
}
val nip19Route = try {
Nip19().uriToRoute(uri)
} catch (e: Exception) {
null
}
if (nip19Route == null) {
Text(text = "$word ")
} else {
ClickableRoute(nip19Route, navController)
}
}
@Composable
fun TagLink(word: String, tags: List<List<String>>, navController: NavController) {
val matcher = tagIndex.matcher(word)
@@ -107,17 +208,104 @@ fun TagLink(word: String, tags: List<List<String>>, navController: NavController
if (user != null) {
ClickableUserTag(user, navController)
} else {
Text(text = "${tags[index][1].toShortenHex()} ")
Text(text = "${tags[index][1].toByteArray().toNpub().toShortenHex()} ")
}
} else if (tags[index][0] == "e") {
val note = LocalCache.notes[tags[index][1]]
if (note != null) {
ClickableNoteTag(note, navController)
} else {
Text(text = "${tags[index][1].toShortenHex()} ")
Text(text = "${tags[index][1].toByteArray().toNote().toShortenHex()} ")
}
} else
Text(text = "$word ")
}
}
class ResultOrError(
var result: String?,
var sourceLang: String?,
var targetLang: String?,
var error: Exception?
)
object LanguageTranslatorService {
private val languageIdentification = LanguageIdentification.getClient()
private val languagesSpokenByTheUser = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()).toLanguageTags()
private val usersPreferredLanguage = Locale.getDefault().language
init {
println("LanguagesAAA: ${languagesSpokenByTheUser}")
}
private val translators =
object : LruCache<TranslatorOptions, Translator>(10) {
override fun create(options: TranslatorOptions): Translator {
return Translation.getClient(options)
}
override fun entryRemoved(
evicted: Boolean,
key: TranslatorOptions,
oldValue: Translator,
newValue: Translator?
) {
oldValue.close()
}
}
fun identifyLanguage(text: String): Task<String> {
return languageIdentification.identifyLanguage(text)
}
fun translate(text: String, source: String, target: String): Task<ResultOrError> {
val sourceLangCode = TranslateLanguage.fromLanguageTag(source)
val targetLangCode = TranslateLanguage.fromLanguageTag(target)
if (sourceLangCode == null || targetLangCode == null) {
return Tasks.forCanceled()
}
val options = TranslatorOptions.Builder()
.setSourceLanguage(sourceLangCode)
.setTargetLanguage(targetLangCode)
.build()
val translator = translators[options]
return translator.downloadModelIfNeeded().onSuccessTask {
val tasks = mutableListOf<Task<String>>()
for (paragraph in text.split("\n")) {
tasks.add(translator.translate(paragraph))
}
Tasks.whenAll(tasks).continueWith {
val results: MutableList<String> = ArrayList()
for (task in tasks) {
results.add(task.result)
}
ResultOrError(results.joinToString("\n"), source, target, null)
}
}
}
fun autoTranslate(text: String, target: String): Task<ResultOrError> {
return identifyLanguage(text).onSuccessTask {
if (it == target) {
Tasks.forCanceled()
} else if (it != "und" && !languagesSpokenByTheUser.contains(it)) {
translate(text, it, target)
} else {
Tasks.forCanceled()
}
}
}
fun autoTranslate(text: String): Task<ResultOrError> {
return autoTranslate(text, usersPreferredLanguage)
}
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -32,7 +33,7 @@ fun UrlPreview(url: String, urlText: String, showUrlIfError: Boolean = true) {
})
}
Crossfade(targetState = urlPreviewState) { state ->
Crossfade(targetState = urlPreviewState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is UrlPreviewState.Loaded -> {
UrlPreviewCard(url, state.previewInfo)
@@ -77,7 +77,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUserState by account.userProfile().liveMetadata.observeAsState()
val accountUser = accountUserState?.user
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -7,6 +8,9 @@ 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.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -16,6 +20,7 @@ import androidx.compose.foundation.lazy.LazyColumn
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
@@ -23,7 +28,10 @@ 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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -34,15 +42,35 @@ 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.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import com.google.zxing.qrcode.encoder.Encoder
import com.google.zxing.qrcode.encoder.QRCode
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.components.ZoomableAsyncImage
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
import nostr.postr.toNpub
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.DrawScope
import com.google.zxing.qrcode.encoder.ByteMatrix
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathFillType
@Composable
fun DrawerContent(navController: NavHostController,
@@ -53,52 +81,26 @@ fun DrawerContent(navController: NavHostController,
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colors.background
) {
Column() {
Box {
val banner = accountUser?.info?.banner
if (banner != null && banner.isNotBlank()) {
AsyncImage(
model = banner,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = "Profile Banner",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
}
ProfileContent(
accountUser,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 25.dp)
.padding(top = 100.dp),
scaffoldState,
navController
)
}
ProfileContent(
account.userProfile(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 25.dp)
.padding(top = 100.dp),
scaffoldState,
navController
)
Divider(
thickness = 0.25.dp,
modifier = Modifier.padding(top = 20.dp)
)
ListContent(
accountUser,
account.userProfile(),
navController,
scaffoldState,
modifier = Modifier
@@ -106,49 +108,80 @@ fun DrawerContent(navController: NavHostController,
.weight(1F),
accountStateViewModel
)
BottomContent(account.userProfile(), scaffoldState, navController)
}
}
}
@Composable
fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier, scaffoldState: ScaffoldState, navController: NavController) {
fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffoldState: ScaffoldState, navController: NavController) {
val coroutineScope = rememberCoroutineScope()
Column(modifier = modifier) {
AsyncImage(
model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png",
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${accountUser?.pubkeyHex}.png"),
modifier = Modifier
.width(100.dp)
.height(100.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colors.background, CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = {
accountUser?.let {
navController.navigate("User/${it.pubkeyHex}")
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})
)
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?.followers?.size ?: "--"}", fontWeight = FontWeight.Bold)
Text(" Followers")
val accountUserState by baseAccountUser.liveMetadata.observeAsState()
val accountUser = accountUserState?.user ?: return
val accountUserFollowsState by baseAccountUser.liveFollows.observeAsState()
val accountUserFollows = accountUserFollowsState?.user ?: return
Box {
val banner = accountUser.info.banner
if (banner != null && banner.isNotBlank()) {
AsyncImage(
model = banner,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = "Profile Banner",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
}
Column(modifier = modifier) {
AsyncImage(
model = accountUser.profilePicture(),
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${accountUser.pubkeyHex}.png"),
modifier = Modifier
.width(100.dp)
.height(100.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colors.background, CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = {
accountUser.let {
navController.navigate("User/${it.pubkeyHex}")
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})
)
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("${accountUserFollows.follows?.size ?: "--"}", fontWeight = FontWeight.Bold)
Text(" Following")
}
Row(modifier = Modifier.padding(start = 10.dp)) {
Text("${accountUserFollows.followers?.size ?: "--"}", fontWeight = FontWeight.Bold)
Text(" Followers")
}
}
}
}
@@ -239,5 +272,73 @@ fun NavigationRow(navController: NavHostController, scaffoldState: ScaffoldState
)
}
}
}
@Composable
fun BottomContent(user: User, scaffoldState: ScaffoldState, navController: NavController) {
val coroutineScope = rememberCoroutineScope()
// store the dialog open or close state
var dialogOpen by remember {
mutableStateOf(false)
}
Column(modifier = Modifier) {
Divider(
modifier = Modifier.padding(top = 15.dp),
thickness = 0.25.dp
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 15.dp),
) {
/*
IconButton(
onClick = {
when (AppCompatDelegate.getDefaultNightMode()) {
AppCompatDelegate.MODE_NIGHT_NO -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
AppCompatDelegate.MODE_NIGHT_YES -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
}
}
) {
Icon(
painter = painterResource(R.drawable.ic_theme),
null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary
)
}*/
Box(modifier = Modifier.weight(1F))
IconButton(onClick = {
dialogOpen = true
coroutineScope.launch {
scaffoldState.drawerState.close()
}
}) {
Icon(
painter = painterResource(R.drawable.ic_qrcode),
null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary
)
}
}
}
if (dialogOpen) {
ShowQRDialog(user,
onScan = {
dialogOpen = false
coroutineScope.launch {
scaffoldState.drawerState.close()
}
navController.navigate(it)
},
onClose = { dialogOpen = false }
)
}
}
}
@@ -115,7 +115,7 @@ private fun homeHasNewItems(cache: NotificationCache): Boolean {
val homeFeed = NostrHomeDataSource.feed().take(100)
val hasNewInFollows = homeFeed.filter {
it.event is RepostEvent || it.replyTo == null || it.replyTo?.size == 0
it.isNewThread()
}.filter {
(it.event?.createdAt ?: 0) > lastTimeFollows
}.isNotEmpty()
@@ -138,7 +138,7 @@ private fun messagesHasNewItems(cache: NotificationCache): Boolean {
return NostrChatroomListDataSource.feed().take(100).filter {
// only for known sources
val me = NostrChatroomListDataSource.account.userProfile()
it.channel != null || me.hasSentMessagesTo(it.author)
it.channel == null && me.hasSentMessagesTo(it.author) && it.author != me
}.filter {
val lastTime = if (it.channel != null) {
cache.load("Channel/${it.channel!!.idHex}", context)
@@ -1,15 +1,24 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
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
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@Composable
fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
@@ -43,7 +52,7 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
@Composable
fun HiddenNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
fun HiddenNote(reports: Set<Note>, loggedIn: User, modifier: Modifier = Modifier, isQuote: Boolean = false, navController: NavController, onClick: () -> Unit) {
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)) {
@@ -56,11 +65,35 @@ fun HiddenNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Post was flagged as inappropriate",
modifier = Modifier.padding(30.dp),
color = Color.Gray,
)
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(30.dp)) {
Text(
text = "Post was flagged as inappropriate by",
color = Color.Gray,
)
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
reports.forEach {
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = loggedIn,
size = 35.dp
)
}
}
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = onClick,
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Show Anyway", color = Color.White)
}
}
}
Divider(
@@ -76,19 +76,12 @@ fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, rou
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()
UserPicture(
user = userState?.user,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
@@ -53,9 +53,6 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user ?: return
val notificationCacheState = NotificationCache.live.observeAsState()
val notificationCache = notificationCacheState.value ?: return
@@ -64,7 +61,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
if (note?.event == null) {
BlankNote(Modifier)
} else if (note.channel != null) {
val authorState by note.author!!.live.observeAsState()
val authorState by note.author!!.liveMetadata.observeAsState()
val author = authorState?.user
val channelState by note.channel!!.live.observeAsState()
@@ -108,25 +105,19 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
}
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
val replyAuthorBase = note.mentions?.first()
var userToComposeOn = author
var userToComposeOn = note.author!!
if ( replyAuthorBase != null ) {
val replyAuthorState by replyAuthorBase.live.observeAsState()
val replyAuthor = replyAuthorState?.user
if (author == accountUser) {
userToComposeOn = replyAuthor
if (replyAuthorBase != null) {
if (note.author == account.userProfile()) {
userToComposeOn = replyAuthorBase
}
}
val noteEvent = note.event
userToComposeOn?.let { user ->
userToComposeOn.let { user ->
val hasNewMessages =
if (noteEvent != null)
noteEvent.createdAt > notificationCache.cache.load("Room/${userToComposeOn.pubkeyHex}", context)
@@ -134,8 +125,8 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
false
ChannelName(
channelPicture = { UserPicture(user = user, userAccount = accountUser, size = 55.dp) },
channelTitle = { UsernameDisplay(user, it) },
channelPicture = { UserPicture(userToComposeOn, account.userProfile(), size = 55.dp) },
channelTitle = { UsernameDisplay(userToComposeOn, it) },
channelLastTime = noteEvent?.createdAt,
channelLastContent = accountViewModel.decrypt(note),
hasNewMessages = hasNewMessages,
@@ -59,24 +59,33 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
val noteReportsState by baseNote.liveReports.observeAsState()
val noteForReports = noteReportsState?.note ?: return
val accountUser = account.userProfile()
var popupExpanded by remember { mutableStateOf(false) }
var showHiddenNote by remember { mutableStateOf(false) }
val context = LocalContext.current.applicationContext
if (note?.event == null) {
BlankNote(Modifier)
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
Modifier,
innerQuote,
navController,
onClick = { showHiddenNote = true }
)
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
var backgroundBubbleColor: Color
var alignment: Arrangement.Horizontal
var shape: Shape
if (author == accountUser) {
if (note.author == accountUser) {
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
alignment = Arrangement.End
shape = ChatBubbleShapeMe
@@ -125,6 +134,9 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp),
) {
val authorState by note.author!!.liveMetadata.observeAsState()
val author = authorState?.user
if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -76,19 +76,12 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn
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()
UserPicture(
user = userState?.user,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
@@ -20,6 +20,7 @@ import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.LocalContentColor
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@@ -38,6 +39,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
@@ -51,6 +53,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -74,7 +77,11 @@ fun NoteCompose(
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
val noteReportsState by baseNote.liveReports.observeAsState()
val noteForReports = noteReportsState?.note ?: return
var popupExpanded by remember { mutableStateOf(false) }
var showHiddenNote by remember { mutableStateOf(false) }
val context = LocalContext.current.applicationContext
@@ -83,15 +90,16 @@ fun NoteCompose(
onClick = { },
onLongClick = { popupExpanded = true },
), isInnerNote)
} else if (account?.isAcceptable(note) == false) {
HiddenNote(modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true },
), isInnerNote)
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
modifier,
isInnerNote,
navController,
onClick = { showHiddenNote = true }
)
} else {
val authorState by note.author?.live!!.observeAsState()
val author = authorState?.user ?: return // if it has event, it should have an author
val isNew = routeForLastRead?.run {
val lastTime = NotificationCache.load(this, context)
@@ -139,20 +147,20 @@ fun NoteCompose(
Box(modifier = Modifier
.width(55.dp)
.padding(0.dp)) {
UserPicture(author, navController, account.userProfile(), 55.dp)
// boosted picture
val boostedPosts = note.replyTo
if (note.event is RepostEvent && boostedPosts != null && boostedPosts.isNotEmpty()) {
val boostedAuthor = boostedPosts[0].author
Box(
Modifier
.width(30.dp)
.height(30.dp)
.align(Alignment.BottomEnd)) {
UserPicture(boostedAuthor, navController, account.userProfile(), 35.dp,
pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape)
)
NoteAuthorPicture(note, navController, account.userProfile(), 55.dp)
if (note.event is RepostEvent) {
note.replyTo?.lastOrNull()?.let {
Box(
Modifier
.width(30.dp)
.height(30.dp)
.align(Alignment.BottomEnd)) {
NoteAuthorPicture(it, navController, account.userProfile(), 35.dp,
pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape)
)
}
}
}
@@ -191,8 +199,7 @@ fun NoteCompose(
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UsernameDisplay(author, Modifier.weight(1f))
NoteUsernameDisplay(note, Modifier.weight(1f))
if (note.event !is RepostEvent) {
Text(
@@ -218,9 +225,9 @@ fun NoteCompose(
}
if (note.event is ReactionEvent || note.event is RepostEvent) {
note.replyTo?.mapIndexed { index, note ->
note.replyTo?.lastOrNull()?.let {
NoteCompose(
note,
it,
modifier = Modifier,
isInnerNote = true,
accountViewModel = accountViewModel,
@@ -239,12 +246,21 @@ fun NoteCompose(
}
} else {
val eventContent = note.event?.content
if (eventContent != null)
RichTextViewer(eventContent, note.event?.tags, navController)
if (eventContent != null) {
if (note.reports.size > 0) {
// Doesn't load images
Row() {
Text(
text = eventContent,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
} else {
RichTextViewer(eventContent, note.event?.tags, navController)
}
}
//if (note.event !is ChannelMessageEvent) {
ReactionsRow(note, accountViewModel)
//}
Divider(
modifier = Modifier.padding(top = 10.dp),
@@ -259,40 +275,42 @@ fun NoteCompose(
}
}
@Composable
fun UserPicture(
user: User?,
fun NoteAuthorPicture(
note: Note,
navController: NavController,
userAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier
) {
UserPicture(user, userAccount, size, pictureModifier) {
user?.let {
navController.navigate("User/${it.pubkeyHex}")
}
NoteAuthorPicture(note, userAccount, size, pictureModifier) {
navController.navigate("User/${it.pubkeyHex}")
}
}
@Composable
fun UserPicture(
user: User?,
userAccount: User,
fun NoteAuthorPicture(
baseNote: Note,
baseUserAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier,
onClick: (() -> Unit)? = null
onClick: ((User) -> Unit)? = null
) {
val accountState by userAccount.live.observeAsState()
val accountUser = accountState?.user ?: return
val noteState by baseNote.live.observeAsState()
val note = noteState?.note ?: return
val author = note.author
Box(
Modifier
.width(size)
.height(size)) {
if (user == null) {
if (author == null) {
AsyncImage(
model = "https://robohash.org/ohno.png",
contentDescription = "Profile Image",
contentDescription = "Unknown Author",
placeholder = rememberAsyncImagePainter("https://robohash.org/ohno.png"),
modifier = pictureModifier
.fillMaxSize(1f)
@@ -300,54 +318,88 @@ fun UserPicture(
.background(MaterialTheme.colors.background)
)
} else {
val userState by user.live.observeAsState()
val userLive = userState?.user ?: return
UserPicture(author, baseUserAccount, size, pictureModifier, onClick)
}
}
}
AsyncImage(
model = userLive.profilePicture(),
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${userLive.pubkeyHex}.png"),
modifier = pictureModifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.run {
if (onClick != null)
this.clickable(onClick = onClick)
else
this
}
@Composable
fun UserPicture(
user: User,
navController: NavController,
userAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier
) {
UserPicture(user, userAccount, size, pictureModifier) {
navController.navigate("User/${it.pubkeyHex}")
}
}
)
@Composable
fun UserPicture(
baseUser: User,
baseUserAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier,
onClick: ((User) -> Unit)? = null
) {
val userState by baseUser.liveMetadata.observeAsState()
val user = userState?.user ?: return
if (accountUser.isFollowing(userLive) || userLive == accountUser) {
Box(
Modifier
.width(size.div(3.5f))
.height(size.div(3.5f))
.align(Alignment.TopEnd),
contentAlignment = Alignment.Center
) {
// Background for the transparent checkmark
Text(
"x",
Modifier
.padding(4.dp)
.fillMaxSize()
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
.clip(shape = CircleShape)
)
Box(
Modifier
.width(size)
.height(size)) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"Following",
modifier = Modifier.fillMaxSize(),
tint = Following
)
AsyncImage(
model = user.profilePicture(),
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
modifier = pictureModifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.run {
if (onClick != null)
this.clickable(onClick = { onClick(user) } )
else
this
}
)
val accountState by baseUserAccount.liveFollows.observeAsState()
val accountUser = accountState?.user ?: return
if (accountUser.isFollowing(user) || user == accountUser) {
Box(
Modifier
.width(size.div(3.5f))
.height(size.div(3.5f))
.align(Alignment.TopEnd),
contentAlignment = Alignment.Center
) {
// Background for the transparent checkmark
Text(
"x",
Modifier
.padding(4.dp)
.fillMaxSize()
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
.clip(shape = CircleShape)
)
Icon(
painter = painterResource(R.drawable.ic_verified),
"Following",
modifier = Modifier.fillMaxSize(),
tint = Following
)
}
}
}
}
@@ -364,7 +416,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
Text("Copy Text")
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.author?.pubkey?.toNpub() ?: "")); onDismiss() }) {
Text("Copy User ID")
Text("Copy User PubKey")
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.id.toNote())); onDismiss() }) {
Text("Copy Note ID")
@@ -374,11 +426,37 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
Text("Broadcast")
}
Divider()
DropdownMenuItem(onClick = { accountViewModel.report(note); onDismiss() }) {
Text("Report Post")
}
DropdownMenuItem(onClick = { note.author?.let { accountViewModel.hide(it, context) }; onDismiss() }) {
Text("Hide User")
}
Divider()
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.SPAM);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Spam / Scam")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.IMPERSONATION);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Impersonation")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.EXPLICIT);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Explicit Content")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.ILLEGAL);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Illegal Behaviour")
}
}
}
@@ -30,6 +30,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -60,6 +61,7 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
var popupExpanded by remember { mutableStateOf(false) }
val uri = LocalUriHandler.current
var wantsToReplyTo by remember {
mutableStateOf<Note?>(null)
@@ -153,7 +155,7 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { }
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
) {
Icon(
imageVector = Icons.Outlined.BarChart,
@@ -0,0 +1,126 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun RelayCompose(
relay: User.RelayInfo,
accountViewModel: AccountViewModel,
navController: NavController,
onAddRelay: () -> Unit,
onRemoveRelay: () -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
Column() {
Row(
modifier = Modifier
.padding(start = 12.dp, end = 12.dp, top = 10.dp)
) {
//UserPicture(user, navController, account.userProfile(), 55.dp)
Column(modifier = Modifier
.padding(start = 10.dp)
.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
relay.url.trim().removePrefix("wss://"),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
timeAgo(relay.lastEvent),
maxLines = 1
)
}
Text(
"${relay.counter} posts received",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
if (account.activeRelays()?.none { it.url == relay.url } == true) {
AddRelayButton { onAddRelay() }
} else {
RemoveRelayButton { onRemoveRelay() }
}
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
}
@Composable
fun AddRelayButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onClick,
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Add", color = Color.White)
}
}
@Composable
fun RemoveRelayButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onClick,
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Remove", color = Color.White)
}
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("MMM d, uuuu hh:mm a"))
}
@@ -24,14 +24,14 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@Composable
fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?, navController: NavController) {
fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, navController: NavController) {
ReplyInformation(replyTo, mentions) {
navController.navigate("User/${it.pubkeyHex}")
}
}
@Composable
fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?, prefix: String = "", onUserTagClick: (User) -> Unit) {
fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, prefix: String = "", onUserTagClick: (User) -> Unit) {
FlowRow() {
if (mentions != null && mentions.isNotEmpty()) {
if (replyTo != null && replyTo.isNotEmpty()) {
@@ -44,7 +44,7 @@ fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?, prefix:
val mentionSet = mentions.toSet()
mentionSet.toSet().forEachIndexed { idx, user ->
val innerUserState by user.live.observeAsState()
val innerUserState by user.liveMetadata.observeAsState()
val innerUser = innerUserState?.user
innerUser?.let { myUser ->
@@ -76,7 +76,7 @@ fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?, prefix:
@Composable
fun ReplyInformationChannel(replyTo: MutableList<Note>?, mentions: List<User>?, channel: Channel, navController: NavController) {
fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<User>?, channel: Channel, navController: NavController) {
ReplyInformationChannel(replyTo, mentions, channel,
onUserTagClick = {
navController.navigate("User/${it.pubkeyHex}")
@@ -89,7 +89,7 @@ fun ReplyInformationChannel(replyTo: MutableList<Note>?, mentions: List<User>?,
@Composable
fun ReplyInformationChannel(replyTo: MutableList<Note>?,
fun ReplyInformationChannel(replyTo: List<Note>?,
mentions: List<User>?,
baseChannel: Channel,
prefix: String = "",
@@ -123,7 +123,7 @@ fun ReplyInformationChannel(replyTo: MutableList<Note>?,
val mentionSet = mentions.toSet()
mentionSet.forEachIndexed { idx, user ->
val innerUserState by user.live.observeAsState()
val innerUserState by user.liveMetadata.observeAsState()
val innerUser = innerUserState?.user
innerUser?.let { myUser ->
@@ -4,6 +4,7 @@ import android.text.format.DateUtils
fun timeAgo(mills: Long?): String {
if (mills == null) return " "
if (mills == 0L) return " • never"
var humanReadable = DateUtils.getRelativeTimeSpanString(
mills * 1000,
@@ -34,14 +34,14 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val userState by baseUser.live.observeAsState()
val user = userState?.user ?: return
val userState by account.userProfile().liveFollows.observeAsState()
val userFollows = userState?.user ?: return
val ctx = LocalContext.current.applicationContext
Column(modifier =
Modifier.clickable(
onClick = { navController.navigate("User/${user.pubkeyHex}") }
onClick = { navController.navigate("User/${baseUser.pubkeyHex}") }
)
) {
Row(
@@ -52,13 +52,16 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
top = 10.dp)
) {
UserPicture(user, navController, account.userProfile(), 55.dp)
UserPicture(baseUser, navController, account.userProfile(), 55.dp)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(user)
UsernameDisplay(baseUser)
}
val userState by baseUser.liveMetadata.observeAsState()
val user = userState?.user ?: return
Text(
user.info.about ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
@@ -68,15 +71,15 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
}
Column(modifier = Modifier.padding(start = 10.dp)) {
if (account?.isAcceptable(user) == false) {
if (account.isHidden(baseUser)) {
ShowUserButton {
account.showUser(user.pubkeyHex)
account.showUser(baseUser.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
} else if (account?.userProfile()?.isFollowing(user) == true) {
UnfollowButton { account.unfollow(user) }
} else if (userFollows.isFollowing(baseUser)) {
UnfollowButton { account.unfollow(baseUser) }
} else {
FollowButton { account?.follow(user) }
FollowButton { account.follow(baseUser) }
}
}
}
@@ -3,42 +3,61 @@ 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.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@Composable
fun UsernameDisplay(user: User, weight: Modifier = Modifier) {
if (user.bestUsername() != null || user.bestDisplayName() != null) {
if (user.bestDisplayName().isNullOrBlank()) {
Text(
"@${(user.bestUsername() ?: "")}",
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
} else {
Text(
user.bestDisplayName() ?: "",
fontWeight = FontWeight.Bold,
)
Text(
"@${(user.bestUsername() ?: "")}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
}
} else {
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
val noteState by baseNote.live.observeAsState()
val note = noteState?.note ?: return
val author = note.author
if (author != null) {
UsernameDisplay(author, weight)
}
}
@Composable
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
val userState by baseUser.liveMetadata.observeAsState()
val user = userState?.user ?: return
if (user.bestUsername() != null || user.bestDisplayName() != null) {
if (user.bestDisplayName().isNullOrBlank()) {
Text(
user.pubkeyDisplayHex,
"@${(user.bestUsername() ?: "")}",
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
} else {
Text(
user.bestDisplayName() ?: "",
fontWeight = FontWeight.Bold,
)
Text(
"@${(user.bestUsername() ?: "")}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
}
} else {
Text(
user.pubkeyDisplayHex,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
}
}
@@ -0,0 +1,245 @@
package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.dp
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import com.google.zxing.qrcode.encoder.ByteMatrix
import com.google.zxing.qrcode.encoder.Encoder
import com.google.zxing.qrcode.encoder.QRCode
const val QR_MARGIN_PX = 100f
@Composable
fun QrCodeDrawer(contents: String, modifier: Modifier = Modifier) {
val qrCode = remember(contents) {
createQrCode(contents = contents)
}
val foregroundColor = MaterialTheme.colors.onSurface
Box(
modifier = modifier
.defaultMinSize(48.dp, 48.dp)
.aspectRatio(1f)
.background(MaterialTheme.colors.background)
) {
Canvas(modifier = Modifier.fillMaxSize()) {
// Calculate the height and width of each column/row
val rowHeight = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.height
val columnWidth = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.width
// Draw all of the finder patterns required by the QR spec. Calculate the ratio
// of the number of rows/columns to the width and height
drawQrCodeFinders(
sideLength = size.width,
finderPatternSize = Size(
width = columnWidth * FINDER_PATTERN_ROW_COUNT,
height = rowHeight * FINDER_PATTERN_ROW_COUNT
),
color = foregroundColor
)
// Draw data bits (encoded data part)
drawAllQrCodeDataBits(
bytes = qrCode.matrix,
size = Size(
width = columnWidth,
height = rowHeight
),
color = foregroundColor
)
}
}
}
private typealias Coordinate = Pair<Int, Int>
private fun createQrCode(contents: String): QRCode {
require(contents.isNotEmpty())
return Encoder.encode(
contents,
ErrorCorrectionLevel.Q,
mapOf(
EncodeHintType.CHARACTER_SET to "UTF-8",
EncodeHintType.MARGIN to QR_MARGIN_PX,
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q
)
)
}
fun newPath(withPath: Path.() -> Unit) = Path().apply {
fillType = PathFillType.EvenOdd
withPath(this)
}
fun DrawScope.drawAllQrCodeDataBits(
bytes: ByteMatrix,
size: Size,
color: Color,
) {
setOf(
// data bits between top left finder pattern and top right finder pattern.
Pair(
first = Coordinate(first = FINDER_PATTERN_ROW_COUNT, second = 0),
second = Coordinate(
first = (bytes.width - FINDER_PATTERN_ROW_COUNT),
second = FINDER_PATTERN_ROW_COUNT
)
),
// data bits below top left finder pattern and above bottom left finder pattern.
Pair(
first = Coordinate(first = 0, second = FINDER_PATTERN_ROW_COUNT),
second = Coordinate(
first = bytes.width,
second = bytes.height - FINDER_PATTERN_ROW_COUNT
)
),
// data bits to the right of the bottom left finder pattern.
Pair(
first = Coordinate(
first = FINDER_PATTERN_ROW_COUNT,
second = (bytes.height - FINDER_PATTERN_ROW_COUNT)
),
second = Coordinate(
first = bytes.width,
second = bytes.height
)
)
).forEach { section ->
for (y in section.first.second until section.second.second) {
for (x in section.first.first until section.second.first) {
if (bytes[x, y] == 1.toByte()) {
drawPath(
color = color,
path = newPath {
addRect(
rect = Rect(
offset = Offset(
x = QR_MARGIN_PX + x * size.width,
y = QR_MARGIN_PX + y * size.height
),
size = size
)
)
}
)
}
}
}
}
}
const val FINDER_PATTERN_ROW_COUNT = 7
private const val INTERIOR_EXTERIOR_SHAPE_RATIO = 3f / FINDER_PATTERN_ROW_COUNT
private const val INTERIOR_EXTERIOR_OFFSET_RATIO = 2f / FINDER_PATTERN_ROW_COUNT
private const val INTERIOR_EXTERIOR_SHAPE_CORNER_RADIUS = 0.12f
private const val INTERIOR_BACKGROUND_EXTERIOR_SHAPE_RATIO = 5f / FINDER_PATTERN_ROW_COUNT
private const val INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO = 1f / FINDER_PATTERN_ROW_COUNT
private const val INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS = 0.5f
/**
* A valid QR code has three finder patterns (top left, top right, bottom left).
*
* @param qrCodeProperties how the QR code is drawn
* @param sideLength length, in pixels, of each side of the QR code
* @param finderPatternSize [Size] of each finder patten, based on the QR code spec
*/
internal fun DrawScope.drawQrCodeFinders(
sideLength: Float,
finderPatternSize: Size,
color: Color
) {
setOf(
// Draw top left finder pattern.
Offset(x = QR_MARGIN_PX, y = QR_MARGIN_PX),
// Draw top right finder pattern.
Offset(x = sideLength - (QR_MARGIN_PX + finderPatternSize.width), y = QR_MARGIN_PX),
// Draw bottom finder pattern.
Offset(x = QR_MARGIN_PX, y = sideLength - (QR_MARGIN_PX + finderPatternSize.height))
).forEach { offset ->
drawQrCodeFinder(
topLeft = offset,
finderPatternSize = finderPatternSize,
cornerRadius = CornerRadius.Zero,
color = color
)
}
}
/**
* This func is responsible for drawing a single finder pattern, for a QR code
*/
private fun DrawScope.drawQrCodeFinder(
topLeft: Offset,
finderPatternSize: Size,
cornerRadius: CornerRadius,
color: Color
) {
drawPath(
color = color,
path = newPath {
// Draw the outer rectangle for the finder pattern.
addRoundRect(
roundRect = RoundRect(
rect = Rect(
offset = topLeft,
size = finderPatternSize
),
cornerRadius = cornerRadius
)
)
// Draw background for the finder pattern interior (this keeps the arc ratio consistent).
val innerBackgroundOffset = Offset(
x = finderPatternSize.width * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO,
y = finderPatternSize.height * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO
)
addRoundRect(
roundRect = RoundRect(
rect = Rect(
offset = topLeft + innerBackgroundOffset,
size = finderPatternSize * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_RATIO
),
cornerRadius = cornerRadius * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS
)
)
// Draw the inner rectangle for the finder pattern.
val innerRectOffset = Offset(
x = finderPatternSize.width * INTERIOR_EXTERIOR_OFFSET_RATIO,
y = finderPatternSize.height * INTERIOR_EXTERIOR_OFFSET_RATIO
)
addRoundRect(
roundRect = RoundRect(
rect = Rect(
offset = topLeft + innerRectOffset,
size = finderPatternSize * INTERIOR_EXTERIOR_SHAPE_RATIO
),
cornerRadius = cornerRadius * INTERIOR_EXTERIOR_SHAPE_CORNER_RADIUS
)
)
}
)
}
@@ -0,0 +1,167 @@
package com.vitorpamplona.amethyst.ui.qrcode
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.ImageFormat
import android.util.Size
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import com.google.common.util.concurrent.ListenableFuture
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import com.vitorpamplona.amethyst.service.Nip19
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@Composable
fun QrCodeScanner(onScan: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
val cameraExecutor= Executors.newSingleThreadExecutor()
var hasCameraPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
)
}
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted ->
hasCameraPermission = granted
}
)
val analyzer = QRCodeAnalyzer { result ->
result?.let {
try {
val nip19 = Nip19().uriToRoute(it)
val startingPage = when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
else -> null
}
if (startingPage != null) {
onScan(startingPage)
}
} catch (e: Throwable) {
// QR can be anythign. do not throw errors.
}
}
}
DisposableEffect(key1 = true) {
launcher.launch(Manifest.permission.CAMERA)
onDispose() {
cameraProviderFuture.get().unbindAll()
cameraExecutor.shutdown()
}
}
Column() {
if (hasCameraPermission) {
AndroidView(
factory = { context ->
val previewView = PreviewView(context)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
bindPreview(analyzer, previewView, cameraExecutor, cameraProvider, lifecycleOwner)
}, ContextCompat.getMainExecutor(context))
return@AndroidView previewView
},
modifier = Modifier.weight(1f)
)
}
}
}
fun bindPreview(
analyzer: ImageAnalysis.Analyzer,
previewView: PreviewView,
cameraExecutor: ExecutorService,
cameraProvider: ProcessCameraProvider,
lifecycleOwner: LifecycleOwner
) {
val preview = Preview.Builder().build()
val selector = CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build()
preview.setSurfaceProvider(previewView.surfaceProvider)
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
imageAnalysis.setAnalyzer(
cameraExecutor,
analyzer
)
cameraProvider.bindToLifecycle(
lifecycleOwner,
selector,
imageAnalysis,
preview
)
}
class QRCodeAnalyzer(
private val onQrCodeScanned: (result: String?) -> Unit
) : ImageAnalysis.Analyzer {
private val scanningOptions = BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()
fun scanBarcodes(inputImage: InputImage) {
BarcodeScanning.getClient(scanningOptions).process(inputImage)
.addOnSuccessListener { barcodes ->
if (barcodes.isNotEmpty()) {
onQrCodeScanned(barcodes[0].displayValue)
}
}
.addOnFailureListener {
it.printStackTrace()
}
}
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
override fun analyze(imageProxy: ImageProxy) {
imageProxy.image?.let { image ->
val inputImage = InputImage.fromMediaImage(image, imageProxy.imageInfo.rotationDegrees)
scanBarcodes(inputImage)
}
imageProxy.close()
}
}
@@ -0,0 +1,152 @@
package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.qrcode.QrCodeScanner
import nostr.postr.toNpub
@Composable
fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
var presenting by remember { mutableStateOf(true) }
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize(),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onClose)
}
Column(
modifier = Modifier.fillMaxSize().padding(10.dp),
verticalArrangement = Arrangement.Center
) {
if (presenting) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
AsyncImage(
model = user.profilePicture() ?: "https://robohash.org/ohno.png",
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
modifier = Modifier
.width(120.dp)
.height(120.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colors.background, CircleShape)
.background(MaterialTheme.colors.background)
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(" @${user.bestUsername()}", color = Color.LightGray)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
) {
QrCodeDrawer("nostr:${user.pubkey.toNpub()}")
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
) {
Button(
onClick = { presenting = false },
shape = RoundedCornerShape(35.dp),
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Scan QR")
}
}
} else {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
) {
QrCodeScanner(onScan)
}
Button(
onClick = { presenting = true },
shape = RoundedCornerShape(35.dp),
modifier = Modifier
.fillMaxWidth().padding(30.dp)
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Show QR")
}
}
}
}
}
}
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -13,7 +14,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: St
val accountState by accountStateViewModel.accountContent.collectAsState()
Column() {
Crossfade(targetState = accountState) { state ->
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is AccountState.LoggedOff -> {
LoginPage(accountStateViewModel)
@@ -18,12 +18,10 @@ class NoteCard(val note: Note): Card() {
class LikeSetCard(val note: Note, val likeEvents: List<Note>): Card() {
val createdAt = likeEvents.maxOf { it.event?.createdAt ?: 0 }
override fun createdAt(): Long {
return createdAt
}
override fun id() = note.idHex + createdAt
override fun id() = note.idHex + "L" + createdAt
}
class BoostSetCard(val note: Note, val boostEvents: List<Note>): Card() {
@@ -33,7 +31,7 @@ class BoostSetCard(val note: Note, val boostEvents: List<Note>): Card() {
return createdAt
}
override fun id() = note.idHex + createdAt
override fun id() = note.idHex + "B" + createdAt
}
sealed class CardFeedState {
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
@@ -45,7 +46,7 @@ fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewMode
},
) {
Column() {
Crossfade(targetState = feedState) { state ->
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is CardFeedState.Empty -> {
FeedEmpty {
@@ -9,6 +9,8 @@ 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.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import kotlinx.coroutines.CoroutineScope
@@ -56,9 +58,9 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
private fun convertToCard(notes: List<Note>): List<Card> {
val reactionsPerEvent = mutableMapOf<Note, MutableList<Note>>()
notes
.filter { it.event is ReactionEvent }
.filter { it.event is ReactionEvent}
.forEach {
val reactedPost = it.replyTo?.last()
val reactedPost = it.replyTo?.lastOrNull() { it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent }
if (reactedPost != null)
reactionsPerEvent.getOrPut(reactedPost, { mutableListOf() }).add(it)
}
@@ -69,7 +71,7 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
notes
.filter { it.event is RepostEvent }
.forEach {
val boostedPost = it.replyTo?.last()
val boostedPost = it.replyTo?.lastOrNull() { it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent }
if (boostedPost != null)
boostsPerEvent.getOrPut(boostedPost, { mutableListOf() }).add(it)
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
@@ -36,7 +37,7 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode
}
Column() {
Crossfade(targetState = feedState) { state ->
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
@@ -48,7 +49,7 @@ fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountView
},
) {
Column() {
Crossfade(targetState = feedState) { state ->
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
@@ -87,7 +88,7 @@ private fun FeedLoaded(
),
state = listState
) {
itemsIndexed(state.feed.value, key = { index, item -> item.idHex }) { index, item ->
itemsIndexed(state.feed.value, key = { index, item -> if (index == 0) index else item.idHex }) { index, item ->
ChatroomCompose(
item,
accountViewModel = accountViewModel,
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -56,7 +57,7 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo
) {
Column() {
Crossfade(targetState = feedState) { state ->
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
@@ -57,13 +57,16 @@ class NostrChatroomListNewFeedViewModel: FeedViewModel(NostrChatroomListDataSour
class NostrHomeFeedViewModel: FeedViewModel(NostrHomeDataSource) {
override fun newListFromDataSource(): List<Note> {
// Filter: no replies
return dataSource.feed().filter {
it.event is RepostEvent || it.replyTo == null || it.replyTo?.size == 0
}.take(100)
return dataSource.feed().filter { it.isNewThread() }.take(100)
}
}
class NostrHomeRepliesFeedViewModel: FeedViewModel(NostrHomeDataSource) {}
class NostrHomeRepliesFeedViewModel: FeedViewModel(NostrHomeDataSource) {
override fun newListFromDataSource(): List<Note> {
// Filter: only replies
return dataSource.feed().filter {! it.isNewThread() }.take(100)
}
}
abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
@@ -0,0 +1,150 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.note.RelayCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class RelayFeedViewModel: ViewModel() {
val order = compareByDescending<User.RelayInfo> { it.lastEvent }.thenByDescending { it.counter }.thenBy { it.url }
private val _feedContent = MutableStateFlow<List<User.RelayInfo>>(emptyList())
val feedContent = _feedContent.asStateFlow()
var currentUser: User? = null
fun refresh() {
viewModelScope.launch(Dispatchers.Default) {
val beingUsed = currentUser?.relaysBeingUsed?.values ?: emptyList()
val beingUsedSet = currentUser?.relaysBeingUsed?.keys ?: emptySet()
val newRelaysFromRecord = currentUser?.relays?.entries?.mapNotNull {
if (it.key !in beingUsedSet) {
User.RelayInfo(it.key, 0, 0)
} else {
null
}
} ?: emptyList()
val newList = (beingUsed + newRelaysFromRecord).sortedWith(order)
_feedContent.update { newList }
}
}
inner class CacheListener: User.Listener() {
override fun onNewRelayInfo() { invalidateData() }
override fun onRelayChange() { invalidateData() }
}
val listener = CacheListener()
fun subscribeTo(user: User) {
currentUser = user
user.subscribe(listener)
invalidateData()
}
fun unsubscribeTo(user: User) {
user.unsubscribe(listener)
currentUser = null
}
override fun onCleared() {
currentUser?.let { unsubscribeTo(it) }
}
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
}
}
@Composable
fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val feedState by viewModel.feedContent.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
var wantsToAddRelay by remember {
mutableStateOf( "")
}
if (wantsToAddRelay.isNotEmpty())
NewRelayListView({ wantsToAddRelay = "" }, account, wantsToAddRelay)
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
viewModel.refresh()
isRefreshing = false
}
}
SwipeRefresh(
state = swipeRefreshState,
onRefresh = {
isRefreshing = true
},
) {
Column() {
val listState = rememberLazyListState()
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(feedState, key = { _, item -> item.url }) { index, item ->
RelayCompose(item,
accountViewModel = accountViewModel,
navController = navController,
onAddRelay = { wantsToAddRelay = item.url },
onRemoveRelay = { wantsToAddRelay = item.url }
)
}
}
}
}
}
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -39,7 +41,10 @@ import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.HiddenNote
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -69,7 +74,7 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
},
) {
Column() {
Crossfade(targetState = feedState) { state ->
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
@@ -157,15 +162,26 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
val noteReportsState by baseNote.liveReports.observeAsState()
val noteForReports = noteReportsState?.note ?: return
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
var showHiddenNote by remember { mutableStateOf(false) }
if (note?.event == null) {
BlankNote()
} else if (!account.isAcceptable(noteForReports)) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
Modifier,
false,
navController,
onClick = { showHiddenNote = true }
)
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
Column(
Modifier
.fillMaxWidth()
@@ -173,17 +189,21 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
Row(modifier = Modifier
.padding(start = 12.dp, end = 12.dp)
.clickable(onClick = {
author?.let {
note.author?.let {
navController.navigate("User/${it.pubkeyHex}")
}
})
) {
UserPicture(user = author, navController, userAccount = account.userProfile(), size = 55.dp)
NoteAuthorPicture(
note = baseNote,
navController = navController,
userAccount = account.userProfile(),
size = 55.dp
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UsernameDisplay(author)
NoteUsernameDisplay(baseNote)
}
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
@@ -43,7 +44,7 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode
},
) {
Column() {
Crossfade(targetState = feedState) { state ->
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is UserFeedState.Empty -> {
FeedEmpty {
@@ -9,6 +9,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ReportEvent
class AccountViewModel(private val account: Account): ViewModel() {
val accountLiveData: LiveData<AccountState> = account.live.map { it }
@@ -17,8 +18,12 @@ class AccountViewModel(private val account: Account): ViewModel() {
account.reactTo(note)
}
fun report(note: Note) {
account.report(note)
fun report(note: Note, type: ReportEvent.ReportType) {
account.report(note, type)
}
fun report(user: User, type: ReportEvent.ReportType) {
account.report(user, type)
}
fun boost(note: Note) {
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
@@ -14,6 +15,8 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
@@ -62,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.toNpub
@Composable
fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, navController: NavController) {
@@ -112,9 +116,7 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun
capitalization = KeyboardCapitalization.Sentences
),
shape = RoundedCornerShape(25.dp),
modifier = Modifier
.weight(1f, true)
.padding(end = 10.dp),
modifier = Modifier.weight(1f, true),
placeholder = {
Text(
text = "reply here.. ",
@@ -213,10 +215,11 @@ private fun NoteCopyButton(
note: Channel
) {
val clipboardManager = LocalClipboardManager.current
var popupExpanded by remember { mutableStateOf(false) }
Button(
modifier = Modifier.padding(horizontal = 3.dp).width(50.dp),
onClick = { clipboardManager.setText(AnnotatedString(note.id.toNote())) },
onClick = { popupExpanded = true },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
@@ -228,6 +231,15 @@ private fun NoteCopyButton(
imageVector = Icons.Default.Share,
contentDescription = "Copies the Note ID to the clipboard for sharing"
)
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = { popupExpanded = false }
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.id.toNote())); popupExpanded = false }) {
Text("Copy Channel ID (Note) to the Clipboard")
}
}
}
}
@@ -272,7 +284,8 @@ private fun JoinButton(account: Account, channel: Channel, navController: NavCon
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Join", color = Color.White)
}
@@ -293,7 +306,8 @@ private fun LeaveButton(account: Account, channel: Channel, navController: NavCo
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Leave", color = Color.White)
}
@@ -41,6 +41,7 @@ import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -70,13 +71,18 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
}
Column(
modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true)
modifier = Modifier
.fillMaxHeight()
.padding(vertical = 0.dp)
.weight(1f, true)
) {
ChatroomFeedView(feedViewModel, accountViewModel, navController, "Room/${userId}")
}
//LAST ROW
Row(modifier = Modifier.padding(10.dp).fillMaxWidth(),
Row(modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -118,29 +124,29 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
@Composable
fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
val authorState by baseUser.live.observeAsState()
val author = authorState?.user
Column(modifier = Modifier.clickable(
onClick = { navController.navigate("User/${author?.pubkeyHex}") }
onClick = { navController.navigate("User/${baseUser.pubkeyHex}") }
)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val authorState by baseUser.liveMetadata.observeAsState()
val author = authorState?.user
AsyncImage(
model = author?.profilePicture(),
placeholder = rememberAsyncImagePainter("https://robohash.org/${author?.pubkeyHex}.png"),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp).height(35.dp)
.width(35.dp)
.height(35.dp)
.clip(shape = CircleShape)
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UsernameDisplay(author)
UsernameDisplay(baseUser)
}
}
}
@@ -9,14 +9,9 @@ import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
@@ -24,15 +19,8 @@ import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.Routes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.lang.System.currentTimeMillis
import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class)
@@ -65,7 +53,7 @@ fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController)
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
text = {
Text(text = "Follows")
Text(text = "New Threads")
}
)
@@ -73,7 +61,7 @@ fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController)
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = {
Text(text = "Follows + Replies")
Text(text = "Conversations")
}
)
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@@ -61,7 +62,7 @@ fun FloatingButton(navController: NavHostController, accountViewModel: AccountSt
val accountState by accountViewModel.accountContent.collectAsState()
if (currentRoute(navController) == Route.Home.route) {
Crossfade(targetState = accountState) { state ->
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is AccountState.LoggedInViewOnly -> {
// Does nothing.
@@ -77,7 +78,7 @@ fun FloatingButton(navController: NavHostController, accountViewModel: AccountSt
}
if (currentRoute(navController) == Route.Message.route) {
Crossfade(targetState = accountState) { state ->
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is AccountState.LoggedInViewOnly -> {
// Does nothing.
@@ -7,9 +7,11 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -20,7 +22,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Tab
@@ -29,10 +34,13 @@ import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Password
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -70,7 +78,9 @@ import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -84,79 +94,92 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
if (userId == null) return
val ctx = LocalContext.current.applicationContext
DisposableEffect(account) {
NostrUserProfileDataSource.loadUserProfile(userId)
NostrUserProfileFollowersDataSource.loadUserProfile(userId)
NostrUserProfileFollowsDataSource.loadUserProfile(userId)
if (userId != null && accountUser != null) {
DisposableEffect(account) {
NostrUserProfileDataSource.loadUserProfile(userId)
NostrUserProfileFollowersDataSource.loadUserProfile(userId)
NostrUserProfileFollowsDataSource.loadUserProfile(userId)
onDispose {
NostrUserProfileDataSource.stop()
NostrUserProfileFollowsDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
}
onDispose {
NostrUserProfileDataSource.stop()
NostrUserProfileFollowsDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
}
}
val baseUser = NostrUserProfileDataSource.user ?: return
val baseUser = NostrUserProfileDataSource.user ?: return
val userState by baseUser.live.observeAsState()
val user = userState?.user ?: return
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colors.background
) {
Column() {
ProfileHeader(baseUser, navController, account, accountViewModel)
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colors.background
) {
Column() {
ProfileHeader(user, navController, account, accountUser, ctx)
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp)) {
TabRow(
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
},
) {
Tab(
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
text = {
Text(text = "Notes")
}
Column(modifier = Modifier.padding()) {
TabRow(
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = {
Text(text = "${user.follows?.size ?: "--"} Following")
}
)
Tab(
selected = pagerState.currentPage == 2,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } },
text = {
Text(text = "${user.followers?.size ?: "--"} Followers")
}
)
}
HorizontalPager(count = 3, state = pagerState) {
when (pagerState.currentPage) {
0 -> TabNotes(user, accountViewModel, navController)
1 -> TabFollows(user, accountViewModel, navController)
2 -> TabFollowers(user, accountViewModel, navController)
},
) {
Tab(
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
text = {
Text(text = "Notes")
}
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = {
val userState by baseUser.liveFollows.observeAsState()
val userFollows = userState?.user?.follows?.size ?: "--"
Text(text = "$userFollows Follows")
}
)
Tab(
selected = pagerState.currentPage == 2,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } },
text = {
val userState by baseUser.liveFollows.observeAsState()
val userFollows = userState?.user?.followers?.size ?: "--"
Text(text = "$userFollows Followers")
}
)
Tab(
selected = pagerState.currentPage == 3,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(3) } },
text = {
val userState by baseUser.liveRelays.observeAsState()
val userRelaysBeingUsed = userState?.user?.relaysBeingUsed?.size ?: "--"
val userStateRelayInfo by baseUser.liveRelayInfo.observeAsState()
val userRelays = userStateRelayInfo?.user?.relays?.size ?: "--"
Text(text = "$userRelaysBeingUsed / $userRelays Relays")
}
)
}
HorizontalPager(count = 4, state = pagerState) {
when (pagerState.currentPage) {
0 -> TabNotes(baseUser, accountViewModel, navController)
1 -> TabFollows(baseUser, accountViewModel, navController)
2 -> TabFollowers(baseUser, accountViewModel, navController)
3 -> TabRelays(baseUser, accountViewModel, navController)
}
}
}
@@ -166,32 +189,46 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
@Composable
private fun ProfileHeader(
user: User,
baseUser: User,
navController: NavController,
account: Account,
accountUser: User,
ctx: Context
accountViewModel: AccountViewModel
) {
val ctx = LocalContext.current.applicationContext
var popupExpanded by remember { mutableStateOf(false) }
val accountUserState by account.userProfile().liveFollows.observeAsState()
val accountUser = accountUserState?.user ?: return
Box {
val banner = user.info.banner
if (banner != null && banner.isNotBlank()) {
AsyncImage(
model = banner,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
DrawBanner(baseUser)
Box(modifier = Modifier
.padding(horizontal = 10.dp)
.size(40.dp)
.align(Alignment.TopEnd)) {
Button(
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
)
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = "Profile Banner",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
)
.size(30.dp)
.align(Alignment.Center),
onClick = { popupExpanded = true },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.background
),
contentPadding = PaddingValues(0.dp)
) {
Icon(
tint = Color.White,
imageVector = Icons.Default.MoreVert,
contentDescription = "More Options",
)
UserProfileDropDownMenu(baseUser, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
Column(
@@ -207,7 +244,7 @@ private fun ProfileHeader(
) {
UserPicture(
user, navController, account.userProfile(), 100.dp,
baseUser, navController, account.userProfile(), 100.dp,
pictureModifier = Modifier.border(
3.dp,
MaterialTheme.colors.background,
@@ -220,52 +257,88 @@ private fun ProfileHeader(
Row(modifier = Modifier
.height(35.dp)
.padding(bottom = 3.dp)) {
MessageButton(user, navController)
MessageButton(baseUser, navController)
if (accountUser == user && account.isWriteable()) {
if (accountUser == baseUser && account.isWriteable()) {
NSecCopyButton(account)
}
NPubCopyButton(user)
NPubCopyButton(baseUser)
if (accountUser == user) {
if (accountUser == baseUser) {
EditButton(account)
} else {
if (account?.isAcceptable(user) == false) {
if (account.isHidden(baseUser)) {
ShowUserButton {
account.showUser(user.pubkeyHex)
account.showUser(baseUser.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
} else if (accountUser.isFollowing(user)) {
UnfollowButton { account.unfollow(user) }
} else if (accountUser.isFollowing(baseUser)) {
UnfollowButton { account.unfollow(baseUser) }
} else {
FollowButton { account.follow(user) }
FollowButton { account.follow(baseUser) }
}
}
}
}
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
Text(
" @${user.bestUsername()}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Text(
"${user.info.about}",
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
)
DrawAdditionalInfo(baseUser)
Divider(modifier = Modifier.padding(top = 6.dp))
}
}
}
@Composable
private fun DrawAdditionalInfo(baseUser: User) {
val userState by baseUser.liveMetadata.observeAsState()
val user = userState?.user ?: return
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
Text(
" @${user.bestUsername()}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Text(
"${user.info.about}",
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
)
}
@Composable
private fun DrawBanner(baseUser: User) {
val userState by baseUser.liveMetadata.observeAsState()
val user = userState?.user ?: return
val banner = user.info.banner
if (banner != null && banner.isNotBlank()) {
AsyncImage(
model = banner,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
)
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = "Profile Banner",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
)
}
}
@Composable
fun TabNotes(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -308,26 +381,55 @@ fun TabFollowers(user: User, accountViewModel: AccountViewModel, navController:
}
}
@Composable
fun TabRelays(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: RelayFeedViewModel = viewModel()
LaunchedEffect(key1 = user) {
feedViewModel.subscribeTo(user)
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
RelayFeedView(feedViewModel, accountViewModel, navController)
}
}
}
@Composable
private fun NSecCopyButton(
account: Account
) {
val clipboardManager = LocalClipboardManager.current
var popupExpanded by remember { mutableStateOf(false) }
Button(
modifier = Modifier.padding(horizontal = 3.dp).width(50.dp),
onClick = { account.loggedIn.privKey?.let { clipboardManager.setText(AnnotatedString(it.toNsec())) } },
modifier = Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { popupExpanded = true },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
),
)
) {
Icon(
tint = Color.White,
imageVector = Icons.Default.Password,
imageVector = Icons.Default.Key,
contentDescription = "Copies the Nsec ID (your password) to the clipboard for backup"
)
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = { popupExpanded = false }
) {
DropdownMenuItem(onClick = { account.loggedIn.privKey?.let { clipboardManager.setText(AnnotatedString(it.toNsec())) }; popupExpanded = false }) {
Text("Copy Private Key to the Clipboard")
}
}
}
}
@@ -336,10 +438,13 @@ private fun NPubCopyButton(
user: User
) {
val clipboardManager = LocalClipboardManager.current
var popupExpanded by remember { mutableStateOf(false) }
Button(
modifier = Modifier.padding(horizontal = 3.dp).width(50.dp),
onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub())) },
modifier = Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { popupExpanded = true },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
@@ -349,15 +454,26 @@ private fun NPubCopyButton(
Icon(
tint = Color.White,
imageVector = Icons.Default.Share,
contentDescription = "Copies the Note ID to the clipboard for sharing"
contentDescription = "Copies the public key to the clipboard for sharing"
)
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = { popupExpanded = false }
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub())); popupExpanded = false }) {
Text("Copy Public Key (NPub) to the Clipboard")
}
}
}
}
@Composable
private fun MessageButton(user: User, navController: NavController) {
Button(
modifier = Modifier.padding(horizontal = 3.dp).width(50.dp),
modifier = Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { navController.navigate("Room/${user.pubkeyHex}") },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
@@ -384,7 +500,9 @@ private fun EditButton(account: Account) {
NewUserMetadataView({ wantsToEdit = false }, account)
Button(
modifier = Modifier.padding(horizontal = 3.dp).width(50.dp),
modifier = Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { wantsToEdit = true },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
@@ -409,7 +527,8 @@ fun UnfollowButton(onClick: () -> Unit) {
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Unfollow", color = Color.White)
}
@@ -424,7 +543,8 @@ fun FollowButton(onClick: () -> Unit) {
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Follow", color = Color.White, textAlign = TextAlign.Center)
}
@@ -439,9 +559,77 @@ fun ShowUserButton(onClick: () -> Unit) {
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Unblock", color = Color.White)
}
}
@Composable
fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current.applicationContext
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub() ?: "")); onDismiss() }) {
Text("Copy User ID")
}
if ( account.userProfile() != user) {
Divider()
if (account.isHidden(user)) {
DropdownMenuItem(onClick = {
user.let {
accountViewModel.show(
it,
context
)
}; onDismiss()
}) {
Text("Unblock User")
}
} else {
DropdownMenuItem(onClick = { user.let { accountViewModel.hide(it, context) }; onDismiss() }) {
Text("Hide User")
}
}
Divider()
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.SPAM);
user.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Spam / Scam")
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION);
user.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Impersonation")
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.EXPLICIT);
user.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Explicit Content")
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL);
user.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Illegal Behaviour")
}
}
}
}
@@ -210,9 +210,6 @@ fun UserLine(
account: Account,
onClick: () -> Unit
) {
val userState by baseUser.live.observeAsState()
val user = userState?.user ?: return
Column(modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
@@ -226,7 +223,7 @@ fun UserLine(
)
) {
UserPicture(user, account.userProfile(), 55.dp, Modifier, null)
UserPicture(baseUser, account.userProfile(), 55.dp, Modifier, null)
Column(
modifier = Modifier
@@ -234,9 +231,12 @@ fun UserLine(
.weight(1f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(user)
UsernameDisplay(baseUser)
}
val userState by baseUser.liveMetadata.observeAsState()
val user = userState?.user ?: return
Text(
user.info.about?.take(100) ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
@@ -88,7 +88,7 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
Image(
painterResource(id = R.drawable.amethyst),
contentDescription = "App Logo",
modifier = Modifier.size(250.dp),
modifier = Modifier.size(200.dp),
contentScale = ContentScale.Inside
)