Compare commits

..
54 Commits
Author SHA1 Message Date
Vitor Pamplona 0272073636 Merge remote-tracking branch 'origin/HEAD' 2023-03-14 22:01:54 -04:00
Vitor Pamplona 063ef1e3a9 v0.26.2 Fdroid build 2023-03-14 21:59:59 -04:00
Vitor PamplonaandGitHub 4ec7af3265 Merge pull request #279 from maxmoney21m/feature/54-reproducible-build
Add .profm sorting to build.gradle
2023-03-14 21:58:51 -04:00
maxmoney21m f09f472f26 Add .profm sorting to build.gradle 2023-03-15 09:37:57 +08:00
Vitor Pamplona 2e365b67ea Filtering duplicates from Follows list 2023-03-14 19:15:40 -04:00
Vitor Pamplona ee753e5c95 v0.26.1 2023-03-14 19:09:30 -04:00
Vitor Pamplona 8b53d28721 Doesn't crash when a non-hex key is inserted in the contact list 2023-03-14 19:08:06 -04:00
Vitor Pamplona f8bb4a3168 v0.26.0 2023-03-14 18:30:48 -04:00
Vitor Pamplona 1b8923b6d7 Speeding up Notification and Global feed filters 2023-03-14 18:30:26 -04:00
Vitor PamplonaandGitHub 675b59c0d2 Merge pull request #233 from vitorpamplona/less_memory_test_branch
Restructuring User/Note
2023-03-14 17:56:38 -04:00
Vitor Pamplona a71078f90f Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-14 17:46:29 -04:00
Vitor Pamplona d4be271060 Fixing typos in the path 2023-03-14 16:27:28 -04:00
Vitor Pamplona 2d59baf898 Uses unique IDs for each job in Actions 2023-03-14 16:14:17 -04:00
Vitor Pamplona 18f651b67f v0.25.3 Testing Build Scripts 2023-03-14 16:03:44 -04:00
Vitor Pamplona f65e09f388 Adding scripts to release all variants of the app 2023-03-14 15:55:08 -04:00
Vitor PamplonaandGitHub a4bb01ef7d Merge pull request #276 from maxmoney21m/feature/54-fdroid-flavor
Make release channel flavors, remove mlkit from fdroid channel
2023-03-14 15:26:11 -04:00
maxmoney21m e9c0d350e1 Make release channel flavors, remove mlkit from fdroid channel 2023-03-15 02:34:50 +08:00
Vitor Pamplona faa29675db 0.25.2 2023-03-14 13:01:13 -04:00
Vitor PamplonaandGitHub 96990c44a6 Merge pull request #274 from vitorpamplona/pr/273
Pr/273
2023-03-14 12:19:46 -04:00
Vitor PamplonaandGitHub 424fe5fd3c Merge pull request #273 from believethehype/main
Image upload button in Chats
2023-03-14 12:19:17 -04:00
Vitor Pamplona 06d3784261 Moving Upload Image buttons to be inside the TextEdit 2023-03-14 12:02:25 -04:00
Believethehype 9e3c08ade8 Image upload button in Chats 2023-03-14 15:27:58 +01:00
Vitor Pamplona 3b7fc7173d This call already happens inside Login 2023-03-14 09:46:39 -04:00
Vitor Pamplona 105c7b9fe8 Avoids adding an empty string when the list is empty because it will parse it back as a list with 1 element with an empty npub. 2023-03-14 09:46:09 -04:00
Vitor PamplonaandGitHub c0c45d1385 Merge pull request #270 from Bardesss/patch-2
Update NL strings.xml
2023-03-14 08:48:17 -04:00
Vitor PamplonaandGitHub 9b2ce25668 Merge pull request #271 from maxmoney21m/bugfix/account-switch-tabs
Bugfix: Reset feedfilter accounts when switching accounts
2023-03-14 08:48:04 -04:00
Vitor PamplonaandGitHub 7e2b28503e Merge pull request #269 from maxmoney21m/bugfix/apptopbar-bgcolor
Fix AppTopBar background color
2023-03-14 08:47:37 -04:00
maxmoney21m d798db7cf1 Reset FeedFilter account when account changes 2023-03-14 18:23:04 +08:00
BardesssandGitHub aa2f718589 Update NL strings.xml
Added missing strings an fixed some mistakes.
2023-03-14 09:26:35 +00:00
maxmoney21m f3a4a7e143 Fix AppTopBar background color 2023-03-14 13:53:38 +08:00
Vitor Pamplona 732abb70f3 v0.25.1 2023-03-13 22:01:35 -04:00
Vitor Pamplona 8c98a8849c Adding the same button style for before and after granted permissions 2023-03-13 17:57:51 -04:00
Vitor Pamplona 5ce6d06920 Reverting QuickAction to the DropDown Menu until we get reporting functions implemented. 2023-03-13 17:46:09 -04:00
Vitor Pamplona bb6b4c208d Moves SVG generation to fetcher (IO) and avoids the use of TrimIndent, which is too slow (2-10ms each) for this context. 2023-03-13 17:38:30 -04:00
Vitor Pamplona ea63522745 Updating dependencies 2023-03-13 17:36:47 -04:00
Vitor Pamplona 1c740d4d7a Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
2023-03-13 14:51:44 -04:00
Vitor Pamplona 75ed380449 Fixing merge issue 2023-03-13 14:32:21 -04:00
Vitor Pamplona 01818ff458 Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch
# Conflicts:
#	.idea/misc.xml
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt
2023-03-13 14:09:17 -04:00
Vitor Pamplona 9adb376198 Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-10 11:28:13 -05:00
Vitor Pamplona ccf2b06b10 Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-09 15:42:22 -05:00
Vitor Pamplona f2f53c66bd Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-09 09:01:27 -05:00
Vitor Pamplona b93f28f09d Merge branch 'main' into less_memory_test_branch
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt
2023-03-08 17:47:34 -05:00
Vitor Pamplona 38df1ba6f7 Merge commit '40a6feab8a4a36813644c19ca034333b73e98132' into less_memory_test_branch 2023-03-08 17:35:30 -05:00
Vitor Pamplona 6c9aa9b6ff Merge commit '9c7a5b73b3a75a465f78c1d97726e66f0e3032a3' into less_memory_test_branch 2023-03-08 17:35:21 -05:00
Vitor Pamplona 26b581b13c Merge commit 'a41f2c38ec9683f0e9aaca3e4cffb2cd45f21145' into less_memory_test_branch 2023-03-08 17:34:40 -05:00
Vitor Pamplona 6a3cae1c8d Merge commit 'c7ba0bce5bbc3edd8ece9669751b110d59c40052' into less_memory_test_branch 2023-03-08 17:34:29 -05:00
Vitor Pamplona 40ac31983c Merge commit 'c9b859610e660f7e2d906594410bc7fbf2b1914a' into less_memory_test_branch
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt
2023-03-08 17:34:22 -05:00
Vitor Pamplona 8c25667d0f Organize imports to simplify merge 2023-03-08 17:29:36 -05:00
Vitor Pamplona 6acc842d4f Readding LocalCacheImport 2023-03-08 17:18:25 -05:00
Vitor Pamplona fc4b68b264 Merge commit 'f1b6927bb37d1c9887fb48aa5655a6d2df558e81' into less_memory_test_branch
# Conflicts:
#	app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt
#	app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt
2023-03-08 17:17:03 -05:00
Vitor Pamplona 9470560002 Testing Lint Commit 2023-03-08 17:07:56 -05:00
Vitor Pamplona cd950ee946 Merge commit '2d92e1d44491d62005e45adcf3dc1c5a403c386f' into less_memory_test_branch 2023-03-08 17:04:24 -05:00
Vitor Pamplona f9f68e2a4f Merge commit '691b8225035d8fe59539a971a85b1df2f2f84ebe' into less_memory_test_branch 2023-03-08 17:03:42 -05:00
Vitor Pamplona 3fd656da9f Removes several Indexes from User and Notes to see if we can reduce App's memory usage. 2023-03-08 08:47:33 -05:00
47 changed files with 762 additions and 506 deletions
+102 -12
View File
@@ -28,10 +28,19 @@ jobs:
- name: Build AAB
run: ./gradlew clean bundleRelease --stacktrace
- name: Sign AAB
- name: Sign AAB (Google Play)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/bundle/release
releaseDirectory: app/build/outputs/bundle/playRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Sign AAB (F-Droid)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/bundle/fdroidRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@@ -40,10 +49,19 @@ jobs:
- name: Build APK
run: ./gradlew assembleRelease --stacktrace --no-daemon
- name: Sign APK
- name: Sign APK (Google Play)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/release
releaseDirectory: app/build/outputs/apk/play/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Sign APK (F-Droid)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/fdroid/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@@ -60,24 +78,96 @@ jobs:
draft: false
prerelease: false
- name: Upload APK Asset
id: upload-release-asset-apk
# Google Play APK
- name: Upload Play APK Universal Asset
id: upload-release-asset-play-universal-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/release/app-release-unsigned-signed.apk
asset_name: app-release-${{ github.ref_name }}.apk
asset_path: app/build/outputs/apk/play/release/app-play-universal-release-unsigned-signed.apk
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload AAB Asset
id: upload-release-asset-aab
- name: Upload Play APK x86 Asset
id: upload-release-asset-play-x86-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/release/app-release.aab
asset_name: app-release-${{ github.ref_name }}.aab
asset_path: app/build/outputs/apk/play/release/app-play-x86-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload Play APK x86_64 Asset
id: upload-release-asset-play-x86-64-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-x86_64-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
# F-Droid APK
- name: Upload F-Droid APK Universal Asset
id: upload-release-asset-fdroid-universal-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-universal-release-unsigned-signed.apk
asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload F-Droid APK x86 Asset
id: upload-release-asset-fdroid-x86-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload F-Droid APK x86_64 Asset
id: upload-release-asset-fdroid-x86-64-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86_64-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
# Google Play AAB
- name: Upload Google Play AAB Asset
id: upload-release-asset-play-aab
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/playRelease/app-play-release.aab
asset_name: amethyst-googleplay-${{ github.ref_name }}.aab
asset_content_type: application/zip
# FDroid AAB
- name: Upload F-Droid AAB Asset
id: upload-release-asset-fdroid-aab
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/fdroidRelease/app-fdroid-release.aab
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
asset_content_type: application/zip
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+65 -7
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 92
versionName "0.25.0"
versionCode 98
versionName "0.26.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -33,19 +33,45 @@ android {
resValue "string", "app_name", "@string/app_name_debug"
}
}
flavorDimensions "channel"
productFlavors {
play {
dimension "channel"
}
fdroid {
dimension "channel"
}
}
splits {
abi {
enable true
reset()
include "x86", "x86_64"
universalApk true
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = '11'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion "1.4.3"
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
@@ -66,10 +92,10 @@ dependencies {
implementation("androidx.navigation:navigation-compose:$nav_version")
// Observe Live data as State
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-beta02'
implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
implementation 'androidx.compose.material:material:1.4.0-beta02'
implementation "androidx.compose.material:material-icons-extended:1.4.0-beta02"
implementation "androidx.compose.material:material:$compose_ui_version"
implementation "androidx.compose.material:material-icons-extended:$compose_ui_version"
// Lifecycle
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
@@ -135,10 +161,10 @@ dependencies {
implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Local model for language identification
implementation 'com.google.mlkit:language-id:17.0.4'
playImplementation 'com.google.mlkit:language-id:17.0.4'
// Google services model the translate text
implementation 'com.google.mlkit:translate:17.0.1'
playImplementation 'com.google.mlkit:translate:17.0.1'
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
@@ -155,3 +181,35 @@ dependencies {
ktlint {
disabledRules.set(["no-wildcard-imports"])
}
// https://gitlab.com/fdroid/wiki/-/wikis/HOWTO:-diff-&-fix-APKs-for-Reproducible-Builds#differing-assetsdexoptbaselineprofm-easy-to-fix
// NB: Android Studio can't find the imports; this does not affect the
// actual build since Gradle can find them just fine.
import com.android.tools.profgen.ArtProfileKt
import com.android.tools.profgen.ArtProfileSerializer
import com.android.tools.profgen.DexFile
project.afterEvaluate {
tasks.each { task ->
if (task.name.startsWith("compile") && task.name.endsWith("ReleaseArtProfile")) {
task.doLast {
outputs.files.each { file ->
if (file.name.endsWith(".profm")) {
println("Sorting ${file} ...")
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
def profile = ArtProfileKt.ArtProfile(file)
def keys = new ArrayList(profile.profileData.keySet())
def sortedData = new LinkedHashMap()
Collections.sort keys, new DexFile.Companion()
keys.each { key -> sortedData[key] = profile.profileData[key] }
new FileOutputStream(file).with {
write(version.magicBytes$profgen)
write(version.versionBytes$profgen)
version.write$profgen(it, sortedData, "")
}
}
}
}
}
}
}
@@ -0,0 +1,26 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun TranslateableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) = ExpandableRichTextViewer(
content,
canPreview,
modifier,
tags,
backgroundColor,
accountViewModel,
navController
)
@@ -79,7 +79,7 @@ object LocalPreferences {
}
val prefs = encryptedPreferences()
prefs.edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma))
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
}
@@ -101,7 +101,7 @@ object LocalPreferences {
if (accounts.remove(npub)) {
val prefs = encryptedPreferences()
prefs.edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma))
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
}
}
@@ -154,7 +154,7 @@ class Account(
if (!isWriteable()) return null
note.event?.let {
return LnZapRequestEvent.create(it, userProfile().relays?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
return LnZapRequestEvent.create(it, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
}
return null
@@ -167,7 +167,7 @@ class Account(
fun createZapRequestFor(userPubKeyHex: String): LnZapRequestEvent? {
if (!isWriteable()) return null
return LnZapRequestEvent.create(userPubKeyHex, userProfile().relays?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
return LnZapRequestEvent.create(userPubKeyHex, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
}
fun report(note: Note, type: ReportEvent.ReportType) {
@@ -250,7 +250,7 @@ class Account(
val event = if (contactList != null && follows.isNotEmpty()) {
ContactListEvent.create(
follows.plus(Contact(user.pubkeyHex, null)),
userProfile().relays,
contactList.relays(),
loggedIn.privKey!!
)
} else {
@@ -275,7 +275,7 @@ class Account(
if (contactList != null && follows.isNotEmpty()) {
val event = ContactListEvent.create(
follows.filter { it.pubKeyHex != user.pubkeyHex },
userProfile().relays,
contactList.relays(),
loggedIn.privKey!!
)
@@ -319,7 +319,7 @@ class Account(
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMeesage(message: String, toUser: String, replyingTo: Note? = null) {
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
@@ -454,7 +454,7 @@ class Account(
}
private fun updateContactListTo(newContactList: ContactListEvent?) {
if (newContactList?.follows().isNullOrEmpty()) return
if (newContactList?.unverifiedFollowKeySet().isNullOrEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupContactList?.id != newContactList?.id) {
@@ -465,7 +465,7 @@ class Account(
// Takes a User's relay list and adds the types of feeds they are active for.
fun activeRelays(): Array<Relay>? {
var usersRelayList = userProfile().relays?.map {
var usersRelayList = userProfile().latestContactList?.relays()?.map {
val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
} ?: return null
@@ -501,15 +501,23 @@ class Account(
fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
fun followingKeySet(): Set<HexKey> {
return userProfile().latestContactList?.verifiedFollowKeySet ?: emptySet()
}
fun isAcceptable(user: User): Boolean {
return !isHidden(user) && // if user hasn't hided this author
user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
user.countReportAuthorsBy(userProfile().follows) < 5
user.countReportAuthorsBy(followingKeySet()) < 5
}
fun isAcceptableDirect(note: Note): Boolean {
return note.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
note.countReportAuthorsBy(userProfile().follows) < 5 // if it has 5 reports by reliable users
note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users
}
fun isFollowing(user: User): Boolean {
return user.pubkeyHex in followingKeySet()
}
fun isAcceptable(note: Note): Boolean {
@@ -522,7 +530,7 @@ class Account(
}
fun getRelevantReports(note: Note): Set<Note> {
val followsPlusMe = userProfile().follows + userProfile()
val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet()
val innerReports = if (note.event is RepostEvent) {
note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList()
@@ -532,8 +540,9 @@ class Account(
return (
note.reportsBy(followsPlusMe) +
(note.author?.reportsBy(followsPlusMe) ?: emptyList()) +
innerReports
(
note.author?.reportsBy(followsPlusMe) ?: emptyList()
) + innerReports
).toSet()
}
@@ -575,7 +584,7 @@ class Account(
it.cache.spamMessages.snapshot().values.forEach {
if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) {
val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex)
if (userToBlock != userProfile() && userToBlock !in userProfile().follows) {
if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) {
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
}
}
@@ -4,8 +4,27 @@ import android.util.Log
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
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.ContactListEvent
import com.vitorpamplona.amethyst.service.model.DeletionEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.Relay
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.*
@@ -162,24 +181,15 @@ object LocalCache {
return
}
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
val replyTo = tagsWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) }
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, replyTo)
note.loadEvent(event, author, 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.addNote(note)
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
replyTo.forEach {
it.author?.addTaggedPost(note)
}
// Counts the replies
replyTo.forEach {
it.addReply(note)
@@ -207,22 +217,13 @@ object LocalCache {
return
}
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
val replyTo = tagsWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) }
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, mentions, replyTo)
note.loadEvent(event, author, replyTo)
author.addNote(note)
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
replyTo.forEach {
it.author?.addTaggedPost(note)
}
refreshObservers()
}
}
@@ -235,7 +236,7 @@ object LocalCache {
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList<User>(), emptyList<Note>())
note.loadEvent(event, author, emptyList<Note>())
refreshObservers()
}
@@ -252,7 +253,7 @@ object LocalCache {
event.badgeAwardDefinitions().mapNotNull { getOrCreateAddressableNote(it) }
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList(), replyTo)
note.loadEvent(event, author, replyTo)
author.updateAcceptedBadges(note)
@@ -272,12 +273,7 @@ object LocalCache {
val awardees = event.awardees().mapNotNull { checkGetOrCreateUser(it) }
val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, awardees, awardDefinition)
// Adds notifications to users.
awardees.forEach {
it.addTaggedPost(note)
}
note.loadEvent(event, author, awardDefinition)
// Replies of an Badge Definition are Award Events
awardDefinition.forEach {
@@ -287,53 +283,6 @@ object LocalCache {
refreshObservers()
}
private fun findCitations(event: Event): Set<String> {
var citations = mutableSetOf<String>()
// Removes citations from replies:
val matcher = tagSearch.matcher(event.content)
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event.tags[it.toInt()] }
if (tag != null && tag[0] == "e") {
citations.add(tag[1])
}
if (tag != null && tag[0] == "a") {
citations.add(tag[1])
}
} catch (e: Exception) {
}
}
return citations
}
private fun tagsWithoutCitations(event: TextNoteEvent): List<String> {
val repliesTo = event.replyTos()
val tagAddresses = event.taggedAddresses().map { it.toTag() }
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
val citations = findCitations(event)
return if (citations.isEmpty()) {
repliesTo + tagAddresses
} else {
repliesTo.filter { it !in citations }
}
}
private fun tagsWithoutCitations(event: LongTextNoteEvent): List<String> {
val repliesTo = event.replyTos()
val tagAddresses = event.taggedAddresses().map { it.toTag() }
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
val citations = findCitations(event)
return if (citations.isEmpty()) {
repliesTo + tagAddresses
} else {
repliesTo.filter { it !in citations }
}
}
@Suppress("UNUSED_PARAMETER")
fun consume(event: RecommendRelayEvent) {
// // Log.d("RR", event.toJson())
@@ -341,41 +290,11 @@ object LocalCache {
fun consume(event: ContactListEvent) {
val user = getOrCreateUser(event.pubKey)
val follows = event.follows()
val follows = event.unverifiedFollowKeySet()
if (event.createdAt > user.updatedFollowsAt && !follows.isNullOrEmpty()) {
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !follows.isNullOrEmpty()) {
// Saves relay list only if it's a user that is currently been seen
user.latestContactList = event
user.updateFollows(
follows.map {
try {
val pubKey = decodePublicKey(it.pubKeyHex)
getOrCreateUser(pubKey.toHexKey())
} catch (e: Exception) {
Log.w("ContactList Parser", "Ignoring: Could not parse Hex key: ${it.pubKeyHex} in ${event.toJson()}")
// e.printStackTrace()
null
}
}.filterNotNull().toSet(),
event.createdAt
)
// Saves relay list only if it's a user that is currently been seen
try {
if (event.content.isNotEmpty()) {
val relays: Map<String, ContactListEvent.ReadWrite> =
Event.gson.fromJson(
event.content,
object : TypeToken<Map<String, ContactListEvent.ReadWrite>>() {}.type
)
user.updateRelays(relays)
}
} catch (e: Exception) {
Log.w("Relay List Parser", "Relay import issue ${e.message}", e)
e.printStackTrace()
}
user.updateContactList(event)
// Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
}
@@ -397,10 +316,10 @@ object LocalCache {
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateNote(it) }
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
if (recipient != null) {
author.addMessage(recipient, note)
@@ -419,13 +338,11 @@ object LocalCache {
deleteNote.author?.removeNote(deleteNote)
// reverts the add
deleteNote.mentions?.forEach { user ->
user.removeTaggedPost(deleteNote)
user.removeReport(deleteNote)
}
val mentions = deleteNote.event?.tags()?.filter { it.firstOrNull() == "p" }
?.mapNotNull { it.getOrNull(1) }?.mapNotNull { checkGetOrCreateUser(it) }
deleteNote.replyTo?.forEach { replyingNote ->
replyingNote.author?.removeTaggedPost(deleteNote)
mentions?.forEach { user ->
user.removeReport(deleteNote)
}
// Counts the replies
@@ -457,23 +374,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().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
// Prepares user's profile view.
author.addNote(note)
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
// Counts the replies
repliesTo.forEach {
it.addBoost(note)
@@ -493,18 +401,10 @@ object LocalCache {
val repliesTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
// Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
if (
event.content == "" ||
event.content == "+" ||
@@ -544,7 +444,7 @@ object LocalCache {
val repliesTo = event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
// Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
@@ -570,7 +470,7 @@ object LocalCache {
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList(), emptyList())
note.loadEvent(event, author, emptyList())
refreshObservers()
}
@@ -590,7 +490,7 @@ object LocalCache {
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList(), emptyList())
note.loadEvent(event, author, emptyList())
refreshObservers()
}
@@ -631,18 +531,10 @@ object LocalCache {
.mapNotNull { checkGetOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
note.loadEvent(event, author, mentions, replyTo)
note.loadEvent(event, author, replyTo)
// Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
replyTo.forEach {
it.author?.addTaggedPost(note)
}
// Counts the replies
replyTo.forEach {
it.addReply(note)
@@ -671,9 +563,12 @@ object LocalCache {
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
((zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>())
(
(zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()
?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
)
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
if (zapRequest == null) {
Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}")
@@ -682,14 +577,6 @@ object LocalCache {
// Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
repliesTo.forEach {
it.addZap(zapRequest, note)
}
@@ -709,18 +596,10 @@ object LocalCache {
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
// Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
repliesTo.forEach {
it.addZap(note, null)
}
@@ -778,12 +657,9 @@ object LocalCache {
// Doesn't need to clean up the replies and mentions.. Too small to matter.
// reverts the add
it.mentions?.forEach { user ->
user.removeTaggedPost(it)
}
it.replyTo?.forEach { replyingNote ->
replyingNote.author?.removeTaggedPost(it)
}
val mentions =
it.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
?.mapNotNull { checkGetOrCreateUser(it) }
// Counts the replies
it.replyTo?.forEach { _ ->
@@ -795,40 +671,6 @@ object LocalCache {
}
}
fun pruneNonFollows(account: Account) {
val follows = account.userProfile().follows
val knownPMs = account.userProfile().privateChatrooms.filter {
account.userProfile().hasSentMessagesTo(it.key) && account.isAcceptable(it.key)
}
val followsFollow = follows.map {
it.follows
}.flatten()
val followSet = follows.plus(knownPMs).plus(account.userProfile()).plus(followsFollow)
val toBeRemoved = notes
.filter {
(it.value.author == null || it.value.author!! !in followSet) && it.value.event?.kind() == TextNoteEvent.kind && it.value.liveSet?.isInUse() != true
}
toBeRemoved.forEach {
notes.remove(it.key)
}
val toBeRemovedUsers = users
.filter {
(it.value !in followSet) && it.value.liveSet?.isInUse() != true
}
toBeRemovedUsers.forEach {
users.remove(it.key)
}
println("PRUNE: ${toBeRemoved.size} messages removed because they came from NonFollows")
println("PRUNE: ${toBeRemovedUsers.size} users removed because are NonFollows")
}
fun pruneHiddenMessages(account: Account) {
val toBeRemoved = account.hiddenUsers.map {
(users[it]?.notes ?: emptySet())
@@ -841,14 +683,6 @@ object LocalCache {
toBeRemoved.forEach {
it.author?.removeNote(it)
// reverts the add
it.mentions?.forEach { user ->
user.removeTaggedPost(it)
}
it.replyTo?.forEach { replyingNote ->
replyingNote.author?.removeTaggedPost(it)
}
// Counts the replies
it.replyTo?.forEach { masterNote ->
masterNote.removeReply(it)
@@ -872,7 +706,8 @@ object LocalCache {
}
}
class LocalCacheLiveData(val cache: LocalCache) : LiveData<LocalCacheState>(LocalCacheState(cache)) {
class LocalCacheLiveData(val cache: LocalCache) :
LiveData<LocalCacheState>(LocalCacheState(cache)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@@ -35,7 +35,6 @@ open class Note(val idHex: String) {
// They are immutable after that.
var event: EventInterface? = null
var author: User? = null
var mentions: List<User>? = null
var replyTo: List<Note>? = null
// These fields are updated every time an event related to this note is received.
@@ -72,10 +71,9 @@ open class Note(val idHex: String) {
open fun createdAt() = event?.createdAt()
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: List<Note>) {
fun loadEvent(event: Event, author: User, replyTo: List<Note>) {
this.event = event
this.author = author
this.mentions = mentions
this.replyTo = replyTo
liveSet?.metadata?.invalidateData()
@@ -215,15 +213,15 @@ open class Note(val idHex: String) {
return reports[user] ?: emptySet()
}
fun reportAuthorsBy(users: Set<User>): List<User> {
return reports.keys.filter { it in users }
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
return reports.keys.filter { it.pubkeyHex in users }
}
fun countReportAuthorsBy(users: Set<User>): Int {
return reports.keys.count { it in users }
fun countReportAuthorsBy(users: Set<HexKey>): Int {
return reports.keys.count { it.pubkeyHex in users }
}
fun reportsBy(users: Set<User>): List<Note> {
fun reportsBy(users: Set<HexKey>): List<Note> {
return reportAuthorsBy(users).mapNotNull {
reports[it]
}.flatten()
@@ -24,25 +24,14 @@ import java.util.regex.Pattern
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
class Badges(val definition: Note, val awardees: Set<Note>)
class User(val pubkeyHex: String) {
var info: UserMetadata? = null
var updatedFollowsAt: Long = 0
var latestContactList: ContactListEvent? = null
var follows = setOf<User>()
private set
var followers = setOf<User>()
private set
var notes = setOf<Note>()
private set
var taggedPosts = setOf<Note>()
private set
var reports = mapOf<User, Set<Note>>()
private set
@@ -51,9 +40,6 @@ class User(val pubkeyHex: String) {
var zaps = mapOf<Note, Note?>()
private set
var relays: Map<String, ContactListEvent.ReadWrite>? = null
private set
var relaysBeingUsed = mapOf<String, RelayInfo>()
private set
@@ -89,54 +75,25 @@ class User(val pubkeyHex: String) {
return info?.picture
}
fun follow(user: User) {
follows = follows + user
user.followers = user.followers + this
fun updateContactList(event: ContactListEvent) {
if (event.id == latestContactList?.id) return
val oldContactListEvent = latestContactList
latestContactList = event
// Update following of the current user
liveSet?.follows?.invalidateData()
user.liveSet?.follows?.invalidateData()
}
fun unfollow(user: User) {
follows = follows - user
user.followers = user.followers - this
liveSet?.follows?.invalidateData()
user.liveSet?.follows?.invalidateData()
}
fun follow(users: Set<User>) {
follows = follows + users
users.forEach {
if (this !in it.followers && it.liveSet?.isInUse() == true) {
it.followers = it.followers + this
it.liveSet?.follows?.invalidateData()
}
// Update Followers of the past user list
// Update Followers of the new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.follows?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.follows?.invalidateData()
}
liveSet?.follows?.invalidateData()
}
fun unfollow(users: Set<User>) {
follows = follows - users
users.forEach {
if (this in it.followers && it.liveSet?.isInUse() == true) {
it.followers = it.followers - this
it.liveSet?.follows?.invalidateData()
}
}
liveSet?.follows?.invalidateData()
}
fun addTaggedPost(note: Note) {
if (note !in taggedPosts) {
taggedPosts = taggedPosts + note
// No need for Listener yet
}
}
fun removeTaggedPost(note: Note) {
taggedPosts = taggedPosts - note
liveSet?.relays?.invalidateData()
}
fun addNote(note: Note) {
@@ -209,15 +166,15 @@ class User(val pubkeyHex: String) {
return reports[user] ?: emptySet()
}
fun reportAuthorsBy(users: Set<User>): List<User> {
return reports.keys.filter { it in users }
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
return reports.keys.filter { it.pubkeyHex in users }
}
fun countReportAuthorsBy(users: Set<User>): Int {
return reports.keys.count { it in users }
fun countReportAuthorsBy(users: Set<HexKey>): Int {
return reports.keys.count { it.pubkeyHex in users }
}
fun reportsBy(users: Set<User>): List<Note> {
fun reportsBy(users: Set<HexKey>): List<Note> {
return reportAuthorsBy(users).mapNotNull {
reports[it]
}.flatten()
@@ -254,22 +211,6 @@ class User(val pubkeyHex: String) {
liveSet?.relayInfo?.invalidateData()
}
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
val toBeAdded = newFollows - follows
val toBeRemoved = follows - newFollows
follow(toBeAdded)
unfollow(toBeRemoved)
updatedFollowsAt = updateAt
}
fun updateRelays(relayUse: Map<String, ContactListEvent.ReadWrite>) {
// no need to test if relays are different. The Account will check for us.
relays = relayUse
liveSet?.relays?.invalidateData()
}
fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) {
info = newUserInfo
info?.latestMetadata = latestMetadata
@@ -295,7 +236,29 @@ class User(val pubkeyHex: String) {
}
fun isFollowing(user: User): Boolean {
return follows.contains(user)
return (latestContactList)?.unverifiedFollowKeySet()?.toSet()?.let {
return user.pubkeyHex in it
} ?: false
}
fun transientFollowCount(): Int? {
return latestContactList?.unverifiedFollowKeySet()?.size
}
fun transientFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.let { pubkeyHex in it.unverifiedFollowKeySet() } ?: false }
}
fun cachedFollowingKeySet(): Set<HexKey> {
return latestContactList?.verifiedFollowKeySet ?: emptySet()
}
fun cachedFollowCount(): Int? {
return latestContactList?.verifiedFollowKeySet?.size
}
fun cachedFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.let { pubkeyHex in it.unverifiedFollowKeySet() } ?: false }
}
fun hasSentMessagesTo(user: User?): Boolean {
@@ -40,10 +40,10 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.userProfile().follows
val follows = account.followingKeySet()
val followKeys = follows.map {
it.pubkeyHex.substring(0, 6)
it.substring(0, 6)
}
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
@@ -0,0 +1,57 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.tagSearch
open class BaseTextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = taggedUsers()
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun findCitations(): Set<String> {
var citations = mutableSetOf<String>()
// Removes citations from replies:
val matcher = tagSearch.matcher(content)
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { tags[it.toInt()] }
if (tag != null && tag[0] == "e") {
citations.add(tag[1])
}
if (tag != null && tag[0] == "a") {
citations.add(tag[1])
}
} catch (e: Exception) {
}
}
return citations
}
fun tagsWithoutCitations(): List<String> {
val repliesTo = replyTos()
val tagAddresses = taggedAddresses().map { it.toTag() }
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
val citations = findCitations()
return if (citations.isEmpty()) {
repliesTo + tagAddresses
} else {
repliesTo.filter { it !in citations }
}
}
}
@@ -14,7 +14,7 @@ class ChannelCreateEvent(
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun channelInfo() = try {
fun channelInfo(): ChannelData = try {
MetadataEvent.gson.fromJson(content, ChannelData::class.java)
} catch (e: Exception) {
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@@ -17,21 +18,44 @@ class ContactListEvent(
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun follows() = try {
tags.filter { it[0] == "p" }.map { Contact(it[1], it.getOrNull(2)) }
} catch (e: Exception) {
Log.e("ContactListEvent", "can't parse tags as follows: $tags", e)
null
// This function is only used by the user logged in
// But it is used all the time.
val verifiedFollowKeySet: Set<HexKey> by lazy {
tags.filter { it[0] == "p" }.mapNotNull {
it.getOrNull(1)?.let { unverifiedHex: String ->
try {
decodePublicKey(unverifiedHex).toHexKey()
} catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
null
}
}
}.toSet()
}
fun relayUse() = try {
val verifiedFollowKeySetAndMe: Set<HexKey> by lazy {
verifiedFollowKeySet + pubKey
}
fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) }
fun follows() = tags.filter { it[0] == "p" }.mapNotNull {
try {
Contact(decodePublicKey(it[1]).toHexKey(), it.getOrNull(2))
} catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
null
}
}
fun relays(): Map<String, ReadWrite>? = try {
if (content.isNotEmpty()) {
gson.fromJson(content, object : TypeToken<Map<String, ReadWrite>>() {}.type)
gson.fromJson(content, object : TypeToken<Map<String, ReadWrite>>() {}.type) as Map<String, ReadWrite>
} else {
null
}
} catch (e: Exception) {
Log.e("ContactListEvent", "can't parse content as relay lists: $tags", e)
Log.w("ContactListEvent", "Can't parse content as relay lists: $content", e)
null
}
@@ -37,6 +37,10 @@ open class Event(
override fun toJson(): String = gson.toJson(this)
fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
/**
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
*/
@@ -22,4 +22,6 @@ interface EventInterface {
fun checkSignature()
fun hasValidSignature(): Boolean
fun isTaggedUser(loggedInUser: String): Boolean
}
@@ -25,7 +25,12 @@ class LnZapRequestEvent(
companion object {
const val kind = 9734
fun create(originalNote: EventInterface, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
fun create(
originalNote: EventInterface,
relays: Set<String>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
@@ -42,7 +47,12 @@ class LnZapRequestEvent(
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun create(userHex: String, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
fun create(
userHex: String,
relays: Set<String>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(
@@ -12,16 +12,7 @@ class LongTextNoteEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
@@ -12,16 +12,7 @@ class TextNoteEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 1
@@ -218,7 +218,9 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
Row(modifier = Modifier.fillMaxWidth()) {
UploadFromGallery(
isUploading = postViewModel.isUploadingImage
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(bottom = 10.dp)
) {
postViewModel.upload(it, context)
}
@@ -10,6 +10,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
@@ -36,7 +37,10 @@ class NewPostViewModel : ViewModel() {
replyingTo?.let { replyNote ->
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
replyNote.author?.let { replyUser ->
val currentMentions = replyNote.mentions ?: emptyList()
val currentMentions = (replyNote.event as? TextNoteEvent)
?.mentions()
?.map { LocalCache.getOrCreateUser(it) } ?: emptyList()
if (currentMentions.contains(replyUser)) {
this.mentions = currentMentions
} else {
@@ -32,7 +32,7 @@ class NewRelayListViewModel : ViewModel() {
fun clear() {
_relays.update {
var relayFile = account.userProfile().relays
var relayFile = account.userProfile().latestContactList?.relays()
// Ugly, but forces nostr.band as the only search-supporting relay today.
// TODO: Remove when search becomes more available.
@@ -4,23 +4,38 @@ import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun UploadFromGallery(
isUploading: Boolean,
tint: Color,
modifier: Modifier,
onImageChosen: (Uri) -> Unit
) {
val cameraPermissionState =
@@ -43,64 +58,111 @@ fun UploadFromGallery(
}
}
)
} else {
Box() {
TextButton(
modifier = Modifier
.align(Alignment.TopCenter),
enabled = !isUploading,
onClick = {
showGallerySelect = true
}
) {
Icon(
painter = painterResource(id = R.drawable.ic_add_photo),
contentDescription = stringResource(id = R.string.upload_image),
modifier = Modifier
.height(20.dp)
.padding(end = 8.dp),
tint = MaterialTheme.colors.primary
)
}
if (!isUploading) {
Text(stringResource(R.string.upload_image))
} else {
Text(stringResource(R.string.uploading))
}
}
}
UploadBoxButton(isUploading, tint, modifier) {
showGallerySelect = true
}
} else {
Column {
Button(
onClick = { cameraPermissionState.launchPermissionRequest() },
enabled = !isUploading
) {
if (!isUploading) {
Text(stringResource(R.string.upload_image))
} else {
Text(stringResource(R.string.uploading))
}
UploadBoxButton(isUploading, tint, modifier) {
cameraPermissionState.launchPermissionRequest()
}
}
}
@Composable
private fun UploadBoxButton(
isUploading: Boolean,
tint: Color,
modifier: Modifier,
onClick: () -> Unit
) {
Box() {
IconButton(
modifier = modifier.align(Alignment.Center),
enabled = !isUploading,
onClick = {
onClick()
}
) {
if (!isUploading) {
Icon(
imageVector = Icons.Default.AddPhotoAlternate,
contentDescription = stringResource(id = R.string.upload_image),
modifier = Modifier.height(25.dp),
tint = tint
)
} else {
LoadingAnimation()
}
}
}
}
@Composable
fun LoadingAnimation(
indicatorSize: Dp = 20.dp,
circleColors: List<Color> = listOf(
Color(0xFF5851D8),
Color(0xFF833AB4),
Color(0xFFC13584),
Color(0xFFE1306C),
Color(0xFFFD1D1D),
Color(0xFFF56040),
Color(0xFFF77737),
Color(0xFFFCAF45),
Color(0xFFFFDC80),
Color(0xFF5851D8)
),
animationDuration: Int = 1000
) {
val infiniteTransition = rememberInfiniteTransition()
val rotateAnimation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = animationDuration,
easing = LinearEasing
)
)
)
CircularProgressIndicator(
modifier = Modifier
.size(size = indicatorSize)
.rotate(degrees = rotateAnimation)
.border(
width = 4.dp,
brush = Brush.sweepGradient(circleColors),
shape = CircleShape
),
progress = 1f,
strokeWidth = 1.dp,
color = MaterialTheme.colors.background // Set background color
)
}
@Composable
fun GallerySelect(
onImageUri: (Uri?) -> Unit = { }
) {
var hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
onResult = { uri: Uri? ->
onImageUri(uri)
hasLaunched.set(false)
}
)
@Composable
fun LaunchGallery() {
SideEffect {
launcher.launch("image/*")
if (!hasLaunched.getAndSet(true)) {
launcher.launch("image/*")
}
}
}
@@ -1,11 +1,20 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.net.Uri
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.ImageRequest
import java.nio.ByteBuffer
import coil.request.Options
import okio.Buffer
import java.security.MessageDigest
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
private fun toHex(color: Color): String {
val argb = color.toArgb()
@@ -40,29 +49,54 @@ private fun svgString(msg: String): String {
val mouth = mouths[mouthIndex]
val accessory = accessories[accIndex]
return """
<svg id="$hashHex" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
<defs>
<style>
.cls-bg{fill:${toHex(bgColor)}}.cls-fill-1{fill:${toHex(fgColor)};}.cls-fill-2{fill:${toHex(fgColor)};}
${body.style}${face.style}${eye.style}${mouth.style}${accessory.style}
</style>
</defs>
<title>Robohash $hashHex</title>
${background}${body.paths}${face.paths}${eye.paths}${mouth.paths}${accessory.paths}
</svg>
""".trimIndent()
return """<svg id="$hashHex" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
<defs>
<style>
.cls-bg{fill:${toHex(bgColor)}}.cls-fill-1{fill:${toHex(fgColor)};}.cls-fill-2{fill:${toHex(fgColor)};}
${body.style}${face.style}${eye.style}${mouth.style}${accessory.style}
</style>
</defs>
<title>Robohash $hashHex</title>
${background}${body.paths}${face.paths}${eye.paths}${mouth.paths}${accessory.paths}
</svg>"""
}
class HashImageFetcher(
private val context: Context,
private val data: Uri
) : Fetcher {
@OptIn(ExperimentalTime::class)
override suspend fun fetch(): SourceResult {
val (value, elapsed) = measureTimedValue {
val source = try {
Buffer().apply { write(svgString(data.toString()).toByteArray()) }
} finally {
}
SourceResult(
source = ImageSource(source, context),
mimeType = null,
dataSource = DataSource.MEMORY
)
}
println("Elapsed: $elapsed")
return value
}
class Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
return HashImageFetcher(options.context, data)
}
}
}
object Robohash {
fun imageRequest(context: Context, message: String): ImageRequest {
return ImageRequest
.Builder(context)
.data(
ByteBuffer.wrap(
svgString(message).toByteArray()
)
)
.data("robohash:$message")
.fetcherFactory(HashImageFetcher.Factory())
.crossfade(100)
.build()
}
@@ -11,6 +11,7 @@ object GlobalFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feed() = LocalCache.notes.values
.asSequence()
.filter {
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) &&
it.replyTo.isNullOrEmpty()
@@ -19,9 +20,10 @@ object GlobalFeedFilter : FeedFilter<Note>() {
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in account.followingChannels()) &&
// does not show people the user already follows
(it.author !in account.userProfile().follows)
(it.author?.pubkeyHex !in account.followingKeySet())
}
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.toList()
.reversed()
}
@@ -15,7 +15,7 @@ object HomeConversationsFeedFilter : FeedFilter<Note>() {
return LocalCache.notes.values
.filter {
(it.event is TextNoteEvent || it.event is RepostEvent) &&
it.author in user.follows &&
it.author?.pubkeyHex in user.cachedFollowingKeySet() &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !HomeNewThreadFeedFilter.account.isHidden(it) } ?: true &&
!it.isNewThread()
@@ -16,7 +16,7 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
val notes = LocalCache.notes.values
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) &&
it.author in user.follows &&
it.author?.pubkeyHex in user.cachedFollowingKeySet() &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
it.isNewThread()
@@ -25,7 +25,7 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
val longFormNotes = LocalCache.addressables.values
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) &&
it.author in user.follows &&
it.author?.pubkeyHex in user.cachedFollowingKeySet() &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
it.isNewThread()
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.*
@@ -8,34 +9,32 @@ object NotificationFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
return account.userProfile()
.taggedPosts
val loggedInUser = account.userProfile()
return LocalCache.notes.values
.asSequence()
.filter {
it.author == null ||
(!account.isHidden(it.author!!) && it.author != account.userProfile())
}
.filter {
it.event !is ChannelCreateEvent &&
it.event !is ChannelMetadataEvent &&
it.event !is LnZapRequestEvent &&
it.event !is BadgeDefinitionEvent &&
it.event !is BadgeProfilesEvent
it.event !is BadgeProfilesEvent &&
it.event?.isTaggedUser(loggedInUser.pubkeyHex) ?: false &&
(it.author == null || (!account.isHidden(it.author!!) && it.author != loggedInUser))
}
.filter { it ->
it.event !is TextNoteEvent ||
it.replyTo?.any { it.author == account.userProfile() } == true ||
account.userProfile() in it.directlyCiteUsers()
it.replyTo?.any { it.author == loggedInUser } == true ||
loggedInUser in it.directlyCiteUsers()
}
.filter {
it.event !is ReactionEvent ||
it.replyTo?.lastOrNull()?.author == account.userProfile() ||
account.userProfile() in it.directlyCiteUsers()
it.replyTo?.lastOrNull()?.author == loggedInUser ||
loggedInUser in it.directlyCiteUsers()
}
.filter {
it.event !is RepostEvent ||
it.replyTo?.lastOrNull()?.author == account.userProfile() ||
account.userProfile() in it.directlyCiteUsers()
it.replyTo?.lastOrNull()?.author == loggedInUser ||
loggedInUser in it.directlyCiteUsers()
}
.sortedBy { it.createdAt() }
.toList()
@@ -14,7 +14,8 @@ object UserProfileFollowersFeedFilter : FeedFilter<User>() {
}
override fun feed(): List<User> {
return user?.followers
?.filter { account.isAcceptable(it) } ?: emptyList()
return user?.let { myUser ->
LocalCache.users.values.filter { it.isFollowing(myUser) && account.isAcceptable(it) }
} ?: emptyList()
}
}
@@ -14,7 +14,9 @@ object UserProfileFollowsFeedFilter : FeedFilter<User>() {
}
override fun feed(): List<User> {
return user?.follows
return user?.latestContactList?.unverifiedFollowKeySet()?.map {
LocalCache.getOrCreateUser(it)
}?.toSet()
?.filter { account.isAcceptable(it) }
?.reversed() ?: emptyList()
}
@@ -99,6 +99,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
Column() {
TopAppBar(
elevation = 0.dp,
backgroundColor = MaterialTheme.colors.surface,
title = {
Column(
modifier = Modifier.fillMaxWidth(),
@@ -197,11 +197,11 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
})
) {
Row() {
Text("${accountUserFollows.follows.size}", fontWeight = FontWeight.Bold)
Text("${accountUserFollows.cachedFollowCount() ?: "--"}", fontWeight = FontWeight.Bold)
Text(stringResource(R.string.following))
}
Row(modifier = Modifier.padding(start = 10.dp)) {
Text("${accountUserFollows.followers.size}", fontWeight = FontWeight.Bold)
Text("${accountUserFollows.cachedFollowerCount() ?: "--"}", fontWeight = FontWeight.Bold)
Text(stringResource(R.string.followers))
}
}
@@ -41,9 +41,11 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -127,7 +129,10 @@ fun ChatroomCompose(
)
}
} else {
val replyAuthorBase = note.mentions?.first()
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
@@ -344,7 +344,7 @@ fun ChatroomMessageCompose(
}
}
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
@@ -294,7 +294,12 @@ fun NoteCompose(
Spacer(modifier = Modifier.height(3.dp))
if (noteEvent is TextNoteEvent && (note.replyTo != null || note.mentions != null)) {
if (noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
val sortedMentions = noteEvent.mentions()
.map { LocalCache.getOrCreateUser(it) }
.toSet()
.sortedBy { account.userProfile().isFollowing(it) }
val replyingDirectlyTo = note.replyTo?.lastOrNull()
if (replyingDirectlyTo != null && unPackReply) {
NoteCompose(
@@ -316,10 +321,13 @@ fun NoteCompose(
navController = navController
)
} else {
ReplyInformation(note.replyTo, note.mentions, account, navController)
ReplyInformation(note.replyTo, sortedMentions, account, navController)
}
} else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || note.mentions != null)) {
val sortedMentions = note.mentions?.toSet()?.sortedBy { account.userProfile().isFollowing(it) }
} else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions() != null)) {
val sortedMentions = noteEvent.mentions()
.map { LocalCache.getOrCreateUser(it) }
.toSet()
.sortedBy { account.userProfile().isFollowing(it) }
note.channel()?.let {
ReplyInformationChannel(note.replyTo, sortedMentions, it, navController)
@@ -381,14 +389,16 @@ fun NoteCompose(
Text(text = stringResource(R.string.award_granted_to))
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
note.mentions?.forEach {
UserPicture(
user = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
noteEvent.awardees()
.map { LocalCache.getOrCreateUser(it) }
.forEach {
UserPicture(
user = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
note.replyTo?.firstOrNull()?.let {
@@ -52,7 +52,6 @@ class AccountStateViewModel() : ViewModel() {
Account(Persona(Hex.decode(key)))
}
LocalPreferences.updatePrefsForLogin(account)
login(account)
}
@@ -64,7 +63,6 @@ class AccountStateViewModel() : ViewModel() {
fun newKey() {
val account = Account(Persona())
LocalPreferences.updatePrefsForLogin(account)
login(account)
}
@@ -26,6 +26,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.note.ChatroomCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -100,7 +102,11 @@ private fun FeedLoaded(
val route = if (channel != null) {
"Channel/${channel.idHex}"
} else {
val replyAuthorBase = note.mentions?.first()
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == account.userProfile()) {
@@ -53,7 +53,7 @@ class RelayFeedViewModel : ViewModel() {
val beingUsed = currentUser?.relaysBeingUsed?.values ?: emptyList()
val beingUsedSet = currentUser?.relaysBeingUsed?.keys ?: emptySet()
val newRelaysFromRecord = currentUser?.relays?.entries?.mapNotNull {
val newRelaysFromRecord = currentUser?.latestContactList?.relays()?.entries?.mapNotNull {
if (it.key !in beingUsedSet) {
RelayInfo(it.key, 0, 0)
} else {
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.widget.Toast
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -65,7 +66,9 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
@@ -82,9 +85,10 @@ fun ChannelScreen(
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
val context = LocalContext.current
val channelScreenModel: NewPostViewModel = viewModel()
if (account != null && channelId != null) {
val newPost = remember { mutableStateOf(TextFieldValue("")) }
val replyTo = remember { mutableStateOf<Note?>(null) }
ChannelFeedFilter.loadMessagesBetween(account, channelId)
@@ -98,6 +102,9 @@ fun ChannelScreen(
LaunchedEffect(Unit) {
feedViewModel.invalidateData()
channelScreenModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
DisposableEffect(channelId) {
@@ -178,8 +185,10 @@ fun ChannelScreen(
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = newPost.value,
onValueChange = { newPost.value = it },
value = channelScreenModel.message,
onValueChange = {
channelScreenModel.updateMessage(it)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
@@ -195,15 +204,24 @@ fun ChannelScreen(
trailingIcon = {
PostButton(
onPost = {
account.sendChannelMessage(newPost.value.text, channel.idHex, replyTo.value, null)
newPost.value = TextFieldValue("")
account.sendChannelMessage(channelScreenModel.message.text, channel.idHex, replyTo.value, null)
channelScreenModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.refresh() // Don't wait a full second before updating
},
newPost.value.text.isNotBlank(),
isActive = channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage,
modifier = Modifier.padding(end = 10.dp)
)
},
leadingIcon = {
UploadFromGallery(
isUploading = channelScreenModel.isUploadingImage,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(start = 5.dp)
) {
channelScreenModel.upload(it, context)
}
},
colors = TextFieldDefaults.textFieldColors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.widget.Toast
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -36,6 +37,7 @@ 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.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -50,7 +52,9 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
@@ -64,9 +68,10 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatRoomFeedViewModel
fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
val context = LocalContext.current
val chatRoomScreenModel: NewPostViewModel = viewModel()
if (account != null && userId != null) {
val newPost = remember { mutableStateOf(TextFieldValue("")) }
val replyTo = remember { mutableStateOf<Note?>(null) }
ChatroomFeedFilter.loadMessagesBetween(account, userId)
@@ -77,6 +82,9 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
LaunchedEffect(userId) {
feedViewModel.refresh()
chatRoomScreenModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
DisposableEffect(userId) {
@@ -156,8 +164,8 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = newPost.value,
onValueChange = { newPost.value = it },
value = chatRoomScreenModel.message,
onValueChange = { chatRoomScreenModel.updateMessage(it) },
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
@@ -173,15 +181,24 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
trailingIcon = {
PostButton(
onPost = {
account.sendPrivateMeesage(newPost.value.text, userId, replyTo.value)
newPost.value = TextFieldValue("")
account.sendPrivateMessage(chatRoomScreenModel.message.text, userId, replyTo.value)
chatRoomScreenModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.refresh() // Don't wait a full second before updating
},
newPost.value.text.isNotBlank(),
isActive = chatRoomScreenModel.message.text.isNotBlank() && !chatRoomScreenModel.isUploadingImage,
modifier = Modifier.padding(end = 10.dp)
)
},
leadingIcon = {
UploadFromGallery(
isUploading = chatRoomScreenModel.isUploadingImage,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(start = 5.dp)
) {
chatRoomScreenModel.upload(it, context)
}
},
colors = TextFieldDefaults.textFieldColors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
@@ -25,6 +25,8 @@ import com.google.accompanist.pager.PagerState
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
@@ -43,10 +45,12 @@ fun HomeScreen(
scrollToTop: Boolean = false
) {
val coroutineScope = rememberCoroutineScope()
val account = accountViewModel.accountLiveData.value?.account ?: return
LaunchedEffect(accountViewModel) {
HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account
NostrHomeDataSource.resetFilters()
homeFeedViewModel.refresh()
repliesFeedViewModel.refresh()
}
@@ -198,13 +198,13 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
},
{
val userState by baseUser.live().follows.observeAsState()
val userFollows = userState?.user?.follows?.size ?: "--"
val userFollows = userState?.user?.transientFollowCount() ?: "--"
Text(text = "$userFollows ${stringResource(R.string.follows)}")
},
{
val userState by baseUser.live().follows.observeAsState()
val userFollowers = userState?.user?.followers?.size ?: "--"
val userFollowers = userState?.user?.transientFollowerCount() ?: "--"
Text(text = "$userFollowers ${stringResource(id = R.string.followers)}")
},
@@ -236,7 +236,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
val userRelaysBeingUsed = userState?.user?.relaysBeingUsed?.size ?: "--"
val userStateRelayInfo by baseUser.live().relayInfo.observeAsState()
val userRelays = userStateRelayInfo?.user?.relays?.size ?: "--"
val userRelays = userStateRelayInfo?.user?.latestContactList?.relays()?.size ?: "--"
Text(text = "$userRelaysBeingUsed / $userRelays ${stringResource(R.string.relays)}")
}
@@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserCompose
@@ -81,8 +82,11 @@ fun SearchScreen(
scrollToTop: Boolean = false
) {
val lifeCycleOwner = LocalLifecycleOwner.current
val account = accountViewModel.accountLiveData.value?.account ?: return
LaunchedEffect(accountViewModel) {
GlobalFeedFilter.account = account
NostrGlobalDataSource.resetFilters()
feedViewModel.refresh()
}
+33 -14
View File
@@ -8,10 +8,10 @@
<string name="show_anyway">Laat toch zien</string>
<string name="post_was_flagged_as_inappropriate_by">Bericht gemarkeerd als ongepast door</string>
<string name="post_not_found">Bericht niet gevonden</string>
<string name="channel_image">Groepsafbeelding</string>
<string name="channel_image">Kanaalafbeelding</string>
<string name="referenced_event_not_found">Verwezen event niet gevonden</string>
<string name="could_not_decrypt_the_message">Note versleuteld met encryptie</string>
<string name="group_picture">Kanaal-afbeelding</string>
<string name="group_picture">Groepafbeelding</string>
<string name="explicit_content">Expliciete inhoud</string>
<string name="spam">Spam</string>
<string name="impersonation">Imitatie</string>
@@ -53,7 +53,7 @@
<string name="lightning_invoice">Lightning invoice</string>
<string name="pay">Betalen</string>
<string name="lightning_tips">Lightning Tips</string>
<string name="note_to_receiver">Notitie aan ontvanger</string>
<string name="note_to_receiver">Note aan ontvanger</string>
<string name="thank_you_so_much">Hartelijk bedankt!</string>
<string name="amount_in_sats">Bedrag in sats</string>
<string name="send_sats">Verstuur sats</string>
@@ -97,16 +97,16 @@
<string name="uploading">Uploaden…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Gebruiker heeft geen Lightning Adress ingesteld om sats te ontvangen</string>
<string name="reply_here">"hier reageren.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopieert Notitie ID naar klembord om te delen</string>
<string name="copy_channel_id_note_to_the_clipboard">Kopieer kanaal ID (Notitie) naar klembord</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopieert note ID naar klembord om te delen</string>
<string name="copy_channel_id_note_to_the_clipboard">Kopieer kanaal ID (note) naar klembord</string>
<string name="edits_the_channel_metadata">Past de kanaal-metadata aan</string>
<string name="join">Lid worden</string>
<string name="known">Bekend</string>
<string name="new_requests">Nieuw verzoek</string>
<string name="blocked_users">Geblokeerde gebruikers</string>
<string name="new_threads">Nieuwe notities</string>
<string name="new_threads">Nieuwe notes</string>
<string name="conversations">Gesprekken</string>
<string name="notes">Notities</string>
<string name="notes">Notes</string>
<string name="replies">Reacties</string>
<string name="follows">"Volgend"</string>
<string name="reports">"Rapporten"</string>
@@ -174,9 +174,9 @@
<string name="report_hateful_speech">Haatdragende taal rapporteren</string>
<string name="report_nudity_porn">Naaktheid / porno rapporteren</string>
<string name="others">anderen</string>
<string name="mark_all_known_as_read">Alle bekende als gelezen markeren</string>
<string name="mark_all_new_as_read">Alle nieuwe als gelezen markeren</string>
<string name="mark_all_as_read">Alles als gelezen markeren</string>
<string name="mark_all_known_as_read">Bekende markeren als gelezen</string>
<string name="mark_all_new_as_read">Nieuwe markeren als gelezen</string>
<string name="mark_all_as_read">Alles markeren als gelezen</string>
<string name="backup_keys">Back-up sleutels</string>
<string name="account_backup_tips_md" tools:ignore="Typos">
## Sleutel back-up en veiligheidstips
@@ -193,19 +193,38 @@
<string name="badge_award_image_for">"Badge award afbeelding voor %1$s"</string>
<string name="new_badge_award_notif">Je hebt een nieuwe Badge award ontvangen</string>
<string name="award_granted_to">Badge award gegeven aan</string>
<string name="copied_note_text_to_clipboard">Notitie tekst gekopieerd naar klembord</string>
<string name="copied_note_text_to_clipboard">Note tekst gekopieerd naar klembord</string>
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">@npub auteur gekopieerd naar klembord</string>
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">Notitie ID (@note1) gekopieerd naar klembord</string>
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">Note ID (@note1) gekopieerd naar klembord</string>
<string name="select_text_dialog_top">Selecteer tekst</string>
<string name="quick_action_select">Selecteer</string>
<string name="quick_action_share_browser_link">Deel browserlink</string>
<string name="quick_action_share">Delen</string>
<string name="quick_action_copy_user_id">Auteur ID</string>
<string name="quick_action_copy_note_id">Notitie ID</string>
<string name="quick_action_copy_note_id">Note ID</string>
<string name="quick_action_copy_text">Kopieer tekst</string>
<string name="quick_action_delete">Verwijderen</string>
<string name="quick_action_unfollow">Ontvolgen</string>
<string name="quick_action_follow">Volgen</string>
<string name="quick_action_request_deletion_alert_title">Verzoek om te verwijderen</string>
<string name="quick_action_request_deletion_alert_body">Amethyst zal vragen om uw notitie te verwijderen van de relays waarmee u momenteel verbonden bent. Er is geen garantie dat uw notitie permanent wordt verwijderd van deze relays, of van andere relays waar het kan worden opgeslagen.</string>
<string name="quick_action_request_deletion_alert_body">Amethyst zal vragen om uw note te verwijderen van de relays waarmee u momenteel verbonden bent. Er is geen garantie dat uw note permanent wordt verwijderd van deze relays, of van andere relays waar het kan worden opgeslagen.</string>
<string name="github" translatable="false">Github Gist w/ Proof</string>
<string name="telegram" translatable="false">Telegram</string>
<string name="mastodon" translatable="false">Mastodon Post ID w/ Proof</string>
<string name="twitter" translatable="false">Twitter Status w/ Proof</string>
<string name="github_proof_url_template" translatable="false">https://gist.github.com/&lt;user&gt;/&lt;gist&gt;</string>
<string name="telegram_proof_url_template" translatable="false">https://t.me/&lt;proof post&gt;</string>
<string name="mastodon_proof_url_template" translatable="false">https://&lt;server&gt;/&lt;user&gt;/&lt;proof post&gt;</string>
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/&lt;user&gt;/status/&lt;proof post&gt;</string>
<string name="private_conversation_notification">"&lt;Kan privébericht niet ontsleutelen&gt;\n\nJe werd genoemd in een privégesprek tussen %1$s en %2$s."</string>
<string name="quick_action_delete_button">Verwijderen</string>
<string name="quick_action_dont_show_again_button">Niet meer laten zien</string>
<string name="account_switch_add_account_dialog_title">Nieuw account toevoegen</string>
<string name="drawer_accounts">Accounts</string>
<string name="account_switch_select_account">Account selecteren</string>
<string name="account_switch_add_account_btn">Nieuw account toevoegen</string>
<string name="account_switch_active_account">Account activeren</string>
<string name="account_switch_has_private_key">Heeft privésleutel</string>
<string name="account_switch_pubkey_only">Alleen lezen, geen privésleutel</string>
<string name="back">Terug</string>
</resources>
+4 -4
View File
@@ -1,16 +1,16 @@
buildscript {
ext {
fragment_version = "1.5.5"
lifecycle_version = '2.6.0-rc1'
compose_ui_version = '1.4.0-beta02'
lifecycle_version = '2.6.0'
compose_ui_version = '1.4.0-rc01'
nav_version = "2.5.3"
room_version = "2.4.3"
accompanist_version = "0.28.0"
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.4.1' apply false
id 'com.android.library' version '7.4.1' apply false
id 'com.android.application' version '7.4.2' apply false
id 'com.android.library' version '7.4.2' apply false
id 'org.jetbrains.kotlin.android' version '1.8.10' apply false
id 'org.jetbrains.kotlin.jvm' version '1.8.10' apply false
}