Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21cb82a05d | ||
|
|
412c5c557a | ||
|
|
81e31319c7 | ||
|
|
97ec28d2c8 | ||
|
|
c6906c42fd | ||
|
|
7921db3ba9 | ||
|
|
b12413cbff | ||
|
|
07cd1738f1 | ||
|
|
b8aa7e64b3 | ||
|
|
055ed62cb7 | ||
|
|
578c70d272 | ||
|
|
d43f0435d0 | ||
|
|
52978fa237 | ||
|
|
f266a9e964 | ||
|
|
01cb27e6f5 | ||
|
|
38730ea534 | ||
|
|
a955f55fc6 | ||
|
|
a42cdaaf91 | ||
|
|
0633707ec6 | ||
|
|
8768daa06f | ||
|
|
fb0a7b0ae4 | ||
|
|
f8b6b8fb45 | ||
|
|
888b6daa2a | ||
|
|
811c373e4c | ||
|
|
1812ec296e | ||
|
|
59cdd81aec | ||
|
|
9297afc02a | ||
|
|
a8d8be3307 | ||
|
|
53ec6c817c | ||
|
|
b7c4ec8358 | ||
|
|
3426ff307c | ||
|
|
cddb713b2f | ||
|
|
1871fe6177 | ||
|
|
83a8558f23 | ||
|
|
dbc155454f | ||
|
|
80e251acfb | ||
|
|
a597bd7882 | ||
|
|
a53bb53c12 | ||
|
|
4fa7aa54a8 | ||
|
|
a9490e1299 | ||
|
|
2bf36f1cfe | ||
|
|
c781d5eff7 | ||
|
|
e8d84795d5 | ||
|
|
b351a781a9 | ||
|
|
97d9e96e2b | ||
|
|
625b8c3bce | ||
|
|
edd4a4cc2c | ||
|
|
b305ccad1d | ||
|
|
14ca6f1d08 | ||
|
|
ad4e6d6596 | ||
|
|
e94d2b5340 | ||
|
|
0241bf0913 | ||
|
|
36885eb6dc | ||
|
|
c1917855d0 | ||
|
|
0272073636 | ||
|
|
063ef1e3a9 | ||
|
|
4ec7af3265 | ||
|
|
f09f472f26 | ||
|
|
2e365b67ea | ||
|
|
ee753e5c95 | ||
|
|
8b53d28721 | ||
|
|
f8bb4a3168 | ||
|
|
1b8923b6d7 | ||
|
|
675b59c0d2 | ||
|
|
a71078f90f | ||
|
|
d4be271060 | ||
|
|
2d59baf898 | ||
|
|
18f651b67f | ||
|
|
f65e09f388 | ||
|
|
a4bb01ef7d | ||
|
|
e9c0d350e1 | ||
|
|
1c740d4d7a | ||
|
|
75ed380449 | ||
|
|
01818ff458 | ||
|
|
9adb376198 | ||
|
|
ccf2b06b10 | ||
|
|
f2f53c66bd | ||
|
|
b93f28f09d | ||
|
|
38df1ba6f7 | ||
|
|
6c9aa9b6ff | ||
|
|
26b581b13c | ||
|
|
6a3cae1c8d | ||
|
|
40ac31983c | ||
|
|
8c25667d0f | ||
|
|
6acc842d4f | ||
|
|
fc4b68b264 | ||
|
|
9470560002 | ||
|
|
cd950ee946 | ||
|
|
f9f68e2a4f | ||
|
|
3fd656da9f |
@@ -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
|
||||
@@ -68,6 +68,7 @@ render.experimental.xml
|
||||
.idea/**/caches/
|
||||
.idea/**/libraries/
|
||||
.idea/**/shelf/
|
||||
.idea/**/codeStyles
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/.name
|
||||
|
||||
@@ -32,6 +32,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
- [x] Internationalization
|
||||
- [x] Badges (NIP-58)
|
||||
- [x] Hashtags
|
||||
- [ ] Local Database
|
||||
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 94
|
||||
versionName "0.25.2"
|
||||
versionCode 101
|
||||
versionName "0.27.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}'
|
||||
@@ -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
|
||||
)
|
||||
@@ -47,7 +47,8 @@ private object PrefKeys {
|
||||
const val TRANSLATE_TO = "translateTo"
|
||||
const val ZAP_AMOUNTS = "zapAmounts"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
|
||||
}
|
||||
|
||||
@@ -183,7 +184,8 @@ object LocalPreferences {
|
||||
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
|
||||
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
|
||||
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, account.hideDeleteRequestInfo)
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
|
||||
putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName())
|
||||
putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
|
||||
}.apply()
|
||||
@@ -230,7 +232,8 @@ object LocalPreferences {
|
||||
mapOf()
|
||||
}
|
||||
|
||||
val hideDeleteRequestInfo = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false)
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
|
||||
return Account(
|
||||
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
|
||||
@@ -241,7 +244,8 @@ object LocalPreferences {
|
||||
languagePreferences,
|
||||
translateTo,
|
||||
zapAmountChoices,
|
||||
hideDeleteRequestInfo,
|
||||
hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog,
|
||||
latestContactList
|
||||
)
|
||||
}
|
||||
@@ -289,8 +293,8 @@ object LocalPreferences {
|
||||
stringPrefs.forEach { userPrefs.putString(it, appPrefs.getString(it, null)) }
|
||||
stringSetPrefs.forEach { userPrefs.putStringSet(it, appPrefs.getStringSet(it, null)) }
|
||||
userPrefs.putBoolean(
|
||||
PrefKeys.HIDE_DELETE_REQUEST_INFO,
|
||||
appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false)
|
||||
PrefKeys.HIDE_DELETE_REQUEST_DIALOG,
|
||||
appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ object ServiceManager {
|
||||
account?.let {
|
||||
LocalCache.pruneOldAndHiddenMessages(it)
|
||||
LocalCache.pruneHiddenMessages(it)
|
||||
LocalCache.pruneContactLists(it)
|
||||
// LocalCache.pruneNonFollows(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,8 @@ class Account(
|
||||
var languagePreferences: Map<String, String> = mapOf(),
|
||||
var translateTo: String = Locale.getDefault().language,
|
||||
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
|
||||
var hideDeleteRequestInfo: Boolean = false,
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var backupContactList: ContactListEvent? = null
|
||||
) {
|
||||
var transientHiddenUsers: Set<String> = setOf()
|
||||
@@ -90,10 +91,12 @@ class Account(
|
||||
|
||||
val contactList = userProfile().latestContactList
|
||||
val follows = contactList?.follows() ?: emptyList()
|
||||
val followsTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
|
||||
|
||||
if (contactList != null && follows.isNotEmpty()) {
|
||||
val event = ContactListEvent.create(
|
||||
follows,
|
||||
followsTags,
|
||||
relays,
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
@@ -101,7 +104,7 @@ class Account(
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
} else {
|
||||
val event = ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
|
||||
val event = ContactListEvent.create(listOf(), listOf(), relays, loggedIn.privKey!!)
|
||||
|
||||
// Keep this local to avoid erasing a good contact list.
|
||||
// Client.send(event)
|
||||
@@ -154,7 +157,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,10 +170,10 @@ 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) {
|
||||
fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
|
||||
if (!isWriteable()) return
|
||||
|
||||
if (note.hasReacted(userProfile(), "⚠️")) {
|
||||
@@ -185,7 +188,7 @@ class Account(
|
||||
}
|
||||
|
||||
note.event?.let {
|
||||
val event = ReportEvent.create(it, type, loggedIn.privKey!!)
|
||||
val event = ReportEvent.create(it, type, loggedIn.privKey!!, content = content)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event, null)
|
||||
}
|
||||
@@ -245,18 +248,49 @@ class Account(
|
||||
if (!isWriteable()) return
|
||||
|
||||
val contactList = userProfile().latestContactList
|
||||
val follows = contactList?.follows() ?: emptyList()
|
||||
val followingUsers = contactList?.follows() ?: emptyList()
|
||||
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
|
||||
|
||||
val event = if (contactList != null && follows.isNotEmpty()) {
|
||||
val event = if (contactList != null) {
|
||||
ContactListEvent.create(
|
||||
follows.plus(Contact(user.pubkeyHex, null)),
|
||||
userProfile().relays,
|
||||
followingUsers.plus(Contact(user.pubkeyHex, null)),
|
||||
followingTags,
|
||||
contactList.relays(),
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
} else {
|
||||
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
|
||||
ContactListEvent.create(
|
||||
listOf(Contact(user.pubkeyHex, null)),
|
||||
followingTags,
|
||||
relays,
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
}
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
fun follow(tag: String) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val contactList = userProfile().latestContactList
|
||||
val followingUsers = contactList?.follows() ?: emptyList()
|
||||
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
|
||||
|
||||
val event = if (contactList != null) {
|
||||
ContactListEvent.create(
|
||||
followingUsers,
|
||||
followingTags.plus(tag),
|
||||
contactList.relays(),
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
} else {
|
||||
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
|
||||
ContactListEvent.create(
|
||||
followingUsers,
|
||||
followingTags.plus(tag),
|
||||
relays,
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
@@ -270,12 +304,34 @@ class Account(
|
||||
if (!isWriteable()) return
|
||||
|
||||
val contactList = userProfile().latestContactList
|
||||
val follows = contactList?.follows() ?: emptyList()
|
||||
val followingUsers = contactList?.follows() ?: emptyList()
|
||||
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
|
||||
|
||||
if (contactList != null && follows.isNotEmpty()) {
|
||||
if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
|
||||
val event = ContactListEvent.create(
|
||||
follows.filter { it.pubKeyHex != user.pubkeyHex },
|
||||
userProfile().relays,
|
||||
followingUsers.filter { it.pubKeyHex != user.pubkeyHex },
|
||||
followingTags,
|
||||
contactList.relays(),
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun unfollow(tag: String) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val contactList = userProfile().latestContactList
|
||||
val followingUsers = contactList?.follows() ?: emptyList()
|
||||
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
|
||||
|
||||
if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
|
||||
val event = ContactListEvent.create(
|
||||
followingUsers,
|
||||
followingTags.filter { !it.equals(tag, ignoreCase = true) },
|
||||
contactList.relays(),
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
|
||||
@@ -454,7 +510,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 +521,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 +557,27 @@ class Account(
|
||||
|
||||
fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
|
||||
|
||||
fun followingKeySet(): Set<HexKey> {
|
||||
return userProfile().cachedFollowingKeySet() ?: emptySet()
|
||||
}
|
||||
|
||||
fun followingTagSet(): Set<HexKey> {
|
||||
return userProfile().cachedFollowingTagSet() ?: 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 +590,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 +600,9 @@ class Account(
|
||||
|
||||
return (
|
||||
note.reportsBy(followsPlusMe) +
|
||||
(note.author?.reportsBy(followsPlusMe) ?: emptyList()) +
|
||||
innerReports
|
||||
(
|
||||
note.author?.reportsBy(followsPlusMe) ?: emptyList()
|
||||
) + innerReports
|
||||
).toSet()
|
||||
}
|
||||
|
||||
@@ -544,8 +613,13 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun setHideDeleteRequestInfo() {
|
||||
hideDeleteRequestInfo = true
|
||||
fun setHideDeleteRequestDialog() {
|
||||
hideDeleteRequestDialog = true
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun setHideBlockAlertDialog() {
|
||||
hideBlockAlertDialog = true
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
@@ -575,7 +649,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ class Channel(val idHex: String) {
|
||||
notes[note.idHex] = note
|
||||
}
|
||||
|
||||
fun removeNote(note: Note) {
|
||||
notes.remove(note.idHex)
|
||||
}
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.info = channelInfo
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.R
|
||||
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
|
||||
if (tag.lowercase() == "bitcoin" || tag.lowercase() == "btc") {
|
||||
return HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color(0xFFF2A900))
|
||||
} else if (tag.lowercase() == "nostr") {
|
||||
return HashtagIcon(R.drawable.ht_nostr, "Nostr", Color(0xFF9C59FF))
|
||||
}
|
||||
return null
|
||||
}
|
||||
class HashtagIcon(
|
||||
val icon: Int,
|
||||
val description: String,
|
||||
val color: Color
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -269,15 +270,9 @@ object LocalCache {
|
||||
// Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
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 +282,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 +289,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 +315,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 +337,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
|
||||
@@ -437,6 +353,19 @@ object LocalCache {
|
||||
masterNote.removeReport(deleteNote)
|
||||
}
|
||||
|
||||
val channel = deleteNote.channel()
|
||||
channel?.removeNote(deleteNote)
|
||||
|
||||
if (deleteNote.event is PrivateDmEvent) {
|
||||
val author = deleteNote.author
|
||||
val recipient = (deleteNote.event as? PrivateDmEvent)?.recipientPubKey()?.let { checkGetOrCreateUser(it) }
|
||||
|
||||
if (recipient != null && author != null) {
|
||||
author.removeMessage(recipient, deleteNote)
|
||||
recipient.removeMessage(author, deleteNote)
|
||||
}
|
||||
}
|
||||
|
||||
notes.remove(deleteNote.idHex)
|
||||
|
||||
deletedAtLeastOne = true
|
||||
@@ -457,23 +386,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)
|
||||
@@ -489,22 +409,13 @@ object LocalCache {
|
||||
if (note.event != null) return
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) }
|
||||
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 +455,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 +481,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 +501,7 @@ object LocalCache {
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
oldChannel.addNote(note)
|
||||
note.loadEvent(event, author, emptyList(), emptyList())
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
@@ -626,23 +537,14 @@ object LocalCache {
|
||||
return
|
||||
}
|
||||
|
||||
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val replyTo = event.replyTos()
|
||||
.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 +573,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 +587,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 +606,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 +667,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 +681,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 +693,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)
|
||||
@@ -864,6 +708,18 @@ object LocalCache {
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
|
||||
}
|
||||
|
||||
fun pruneContactLists(userAccount: Account) {
|
||||
var removingContactList = 0
|
||||
users.values.forEach {
|
||||
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false)) {
|
||||
it.latestContactList = null
|
||||
removingContactList++
|
||||
}
|
||||
}
|
||||
|
||||
println("PRUNE: $removingContactList contact lists")
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: LocalCacheLiveData = LocalCacheLiveData(this)
|
||||
|
||||
@@ -872,7 +728,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()
|
||||
@@ -241,9 +239,9 @@ open class Note(val idHex: String) {
|
||||
val dayAgo = Date().time / 1000 - 24 * 60 * 60
|
||||
return reports.isNotEmpty() ||
|
||||
(
|
||||
author?.reports?.values?.filter {
|
||||
author?.reports?.values?.any {
|
||||
it.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null
|
||||
}?.isNotEmpty() ?: false
|
||||
} ?: false
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -232,6 +189,7 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addMessage(user: User, msg: Note) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
@@ -240,6 +198,15 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeMessage(user: User, msg: Note) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg in privateChatroom.roomMessages) {
|
||||
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
|
||||
liveSet?.messages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addRelayBeingUsed(relay: Relay, eventTime: Long) {
|
||||
val here = relaysBeingUsed[relay.url]
|
||||
if (here == null) {
|
||||
@@ -254,22 +221,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 +246,51 @@ 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 isFollowingHashtag(tag: String): Boolean {
|
||||
return latestContactList?.unverifiedFollowTagSet()?.toSet()?.let {
|
||||
return tag in it
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun isFollowingHashtagCached(tag: String): Boolean {
|
||||
return latestContactList?.verifiedFollowTagSet?.let {
|
||||
return tag.lowercase() in it
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun isFollowingCached(user: User): Boolean {
|
||||
return latestContactList?.verifiedFollowKeySet?.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 cachedFollowingTagSet(): Set<HexKey> {
|
||||
return latestContactList?.verifiedFollowTagSet ?: 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 {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
|
||||
private var hashtagToWatch: String? = null
|
||||
|
||||
fun createLoadHashtagFilter(): TypedFilter? {
|
||||
val hashToLoad = hashtagToWatch ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())),
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val loadHashtagChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadHashtag(tag: String?) {
|
||||
hashtagToWatch = tag
|
||||
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@@ -58,9 +58,28 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.followingTagSet()
|
||||
|
||||
if (hashToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val followAccountChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
|
||||
followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
|
||||
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
|
||||
val mySearchString = searchString
|
||||
if (mySearchString == null) {
|
||||
if (mySearchString.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -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,28 +18,60 @@ 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 verifiedFollowTagSet: Set<String> by lazy {
|
||||
unverifiedFollowTagSet().map { it.lowercase() }.toSet()
|
||||
}
|
||||
|
||||
val verifiedFollowKeySetAndMe: Set<HexKey> by lazy {
|
||||
verifiedFollowKeySet + pubKey
|
||||
}
|
||||
|
||||
fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun unverifiedFollowTagSet() = tags.filter { it[0] == "t" }.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 followsTags() = tags.filter { it[0] == "t" }.mapNotNull {
|
||||
it.getOrNull(2)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 3
|
||||
|
||||
fun create(follows: List<Contact>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
|
||||
fun create(follows: List<Contact>, followTags: List<String>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
|
||||
val content = if (relayUse != null) {
|
||||
gson.toJson(relayUse)
|
||||
} else {
|
||||
@@ -51,7 +84,10 @@ class ContactListEvent(
|
||||
} else {
|
||||
listOf("p", it.pubKeyHex)
|
||||
}
|
||||
} + followTags.map {
|
||||
listOf("t", it)
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ContactListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
|
||||
@@ -37,6 +37,16 @@ open class Event(
|
||||
|
||||
override fun toJson(): String = gson.toJson(this)
|
||||
|
||||
fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
|
||||
|
||||
override fun isTaggedHash(hashtag: String) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1).equals(hashtag, true) }
|
||||
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }
|
||||
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1)
|
||||
|
||||
/**
|
||||
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
|
||||
*/
|
||||
|
||||
@@ -22,4 +22,10 @@ interface EventInterface {
|
||||
fun checkSignature()
|
||||
|
||||
fun hasValidSignature(): Boolean
|
||||
|
||||
fun isTaggedUser(loggedInUser: String): Boolean
|
||||
|
||||
fun isTaggedHash(hashtag: String): Boolean
|
||||
fun isTaggedHashes(hashtag: Set<String>): Boolean
|
||||
fun firstIsTaggedHashes(hashtag: Set<String>): String?
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -57,9 +57,13 @@ class ReportEvent(
|
||||
companion object {
|
||||
const val kind = 1984
|
||||
|
||||
fun create(reportedPost: EventInterface, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
|
||||
val content = ""
|
||||
|
||||
fun create(
|
||||
reportedPost: EventInterface,
|
||||
type: ReportType,
|
||||
privateKey: ByteArray,
|
||||
content: String = "",
|
||||
createdAt: Long = Date().time / 1000
|
||||
): ReportEvent {
|
||||
val reportPostTag = listOf("e", reportedPost.id(), type.name.lowercase())
|
||||
val reportAuthorTag = listOf("p", reportedPost.pubKey(), type.name.lowercase())
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@@ -12,16 +13,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
|
||||
@@ -38,6 +30,9 @@ class TextNoteEvent(
|
||||
addresses?.forEach {
|
||||
tags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
findHashtags(msg).forEach {
|
||||
tags.add(listOf("t", it))
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
|
||||
|
||||
@@ -1,35 +1,43 @@
|
||||
package com.vitorpamplona.amethyst.service.nip19
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.bechToBytes
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object Nip19 {
|
||||
enum class Type {
|
||||
USER, NOTE, RELAY, ADDRESS
|
||||
}
|
||||
|
||||
data class Return(val type: Type, val hex: String, val relay: String? = null)
|
||||
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
data class Return(val type: Type, val hex: String, val relay: String? = null, val additionalChars: String = "")
|
||||
|
||||
fun uriToRoute(uri: String?): Return? {
|
||||
try {
|
||||
val key = uri?.removePrefix("nostr:") ?: return null
|
||||
if (uri == null) return null
|
||||
|
||||
val bytes = key.bechToBytes()
|
||||
if (key.startsWith("npub")) {
|
||||
return npub(bytes)
|
||||
} else if (key.startsWith("note")) {
|
||||
return note(bytes)
|
||||
} else if (key.startsWith("nprofile")) {
|
||||
return nprofile(bytes)
|
||||
} else if (key.startsWith("nevent")) {
|
||||
return nevent(bytes)
|
||||
} else if (key.startsWith("nrelay")) {
|
||||
return nrelay(bytes)
|
||||
} else if (key.startsWith("naddr")) {
|
||||
return naddr(bytes)
|
||||
try {
|
||||
val matcher = nip19regex.matcher(uri)
|
||||
matcher.find()
|
||||
val uriScheme = matcher.group(1) // nostr:
|
||||
val type = matcher.group(2) // npub1
|
||||
val key = matcher.group(3) // bech32
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
|
||||
val bytes = (type + key).bechToBytes()
|
||||
val parsed = when (type.lowercase()) {
|
||||
"npub1" -> npub(bytes)
|
||||
"note1" -> note(bytes)
|
||||
"nprofile1" -> nprofile(bytes)
|
||||
"nevent1" -> nevent(bytes)
|
||||
"nrelay1" -> nrelay(bytes)
|
||||
"naddr1" -> naddr(bytes)
|
||||
else -> null
|
||||
}
|
||||
return parsed?.copy(additionalChars = additionalChars)
|
||||
} catch (e: Throwable) {
|
||||
println("Issue trying to Decode NIP19 $uri: ${e.message}")
|
||||
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,7 +7,8 @@ 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.text.AnnotatedString
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
@@ -24,58 +25,55 @@ fun ClickableRoute(
|
||||
val userState by userBase.live().metadata.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)
|
||||
)
|
||||
CreateClickableText(user.toBestDisplayName(), nip19.additionalChars, "User/${nip19.hex}", navController)
|
||||
} else if (nip19.type == Nip19.Type.ADDRESS) {
|
||||
val noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
|
||||
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex} "
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
)
|
||||
} else {
|
||||
val noteState by noteBase.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${note.idDisplayNote()} "),
|
||||
onClick = { navController.navigate("Note/${nip19.hex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
|
||||
}
|
||||
} else if (nip19.type == Nip19.Type.NOTE) {
|
||||
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
|
||||
val noteState by noteBase.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
val channel = note.channel()
|
||||
|
||||
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)
|
||||
)
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController)
|
||||
} else if (channel != null) {
|
||||
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController)
|
||||
} else {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${note.idDisplayNote()} "),
|
||||
onClick = { navController.navigate("Note/${nip19.hex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
"@${nip19.hex} "
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateClickableText(clickablePart: String, suffix: String, route: String, navController: NavController) {
|
||||
ClickableText(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||
) {
|
||||
append("@$clickablePart")
|
||||
}
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.onBackground).toSpanStyle()
|
||||
) {
|
||||
append("$suffix ")
|
||||
}
|
||||
},
|
||||
onClick = { navController.navigate(route) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
const val SHORT_TEXT_LENGTH = 350
|
||||
@@ -43,15 +42,14 @@ fun ExpandableRichTextViewer(
|
||||
) {
|
||||
var showFullText by remember { mutableStateOf(false) }
|
||||
|
||||
// Cuts the text in the first space after 350
|
||||
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH)
|
||||
val whereToCut = if (firstSpaceAfterCut < 0) SHORT_TEXT_LENGTH else firstSpaceAfterCut
|
||||
|
||||
val text = if (showFullText) {
|
||||
content
|
||||
} else {
|
||||
val (lnStart, lnEnd) = LnInvoiceUtil.locateInvoice(content)
|
||||
if (lnStart < SHORT_TEXT_LENGTH && lnEnd > 0) {
|
||||
content.take(lnEnd)
|
||||
} else {
|
||||
content.take(SHORT_TEXT_LENGTH)
|
||||
}
|
||||
content.take(whereToCut)
|
||||
}
|
||||
|
||||
Box(contentAlignment = Alignment.BottomCenter) {
|
||||
@@ -67,7 +65,7 @@ fun ExpandableRichTextViewer(
|
||||
)
|
||||
// }
|
||||
|
||||
if (content.length > 350 && !showFullText) {
|
||||
if (content.length > whereToCut && !showFullText) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.util.Log
|
||||
import android.util.Patterns
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
@@ -17,11 +18,14 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
@@ -32,6 +36,7 @@ import com.halilibo.richtext.ui.RichTextStyle
|
||||
import com.halilibo.richtext.ui.material.MaterialRichText
|
||||
import com.halilibo.richtext.ui.resolveDefaults
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
@@ -41,13 +46,13 @@ import java.net.URISyntaxException
|
||||
import java.net.URL
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$")
|
||||
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm|mov)$")
|
||||
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$", Pattern.CASE_INSENSITIVE)
|
||||
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm|mov)$", Pattern.CASE_INSENSITIVE)
|
||||
val noProtocolUrlValidator = Pattern.compile("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$")
|
||||
val tagIndex = Pattern.compile(".*\\#\\[([0-9]+)\\].*")
|
||||
|
||||
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_-]+)")
|
||||
val hashTagsPattern: Pattern = Pattern.compile("#([A-Za-z0-9_-]+)")
|
||||
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_\\-]+)")
|
||||
val hashTagsPattern: Pattern = Pattern.compile("#([a-z0-9_\\-]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||
val urlPattern: Pattern = Patterns.WEB_URL
|
||||
|
||||
fun isValidURL(url: String?): Boolean {
|
||||
@@ -144,6 +149,8 @@ fun RichTextViewer(
|
||||
UrlPreview("https://$word", word)
|
||||
} else if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, accountViewModel, navController)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(word, navController)
|
||||
} else {
|
||||
@@ -163,6 +170,8 @@ fun RichTextViewer(
|
||||
ClickableUrl(word, "https://$word")
|
||||
} else if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, accountViewModel, navController)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(word, navController)
|
||||
} else {
|
||||
@@ -191,19 +200,7 @@ fun isBechLink(word: String): Boolean {
|
||||
|
||||
@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
|
||||
}
|
||||
val nip19Route = Nip19.uriToRoute(word)
|
||||
|
||||
if (nip19Route == null) {
|
||||
Text(text = "$word ")
|
||||
@@ -212,6 +209,47 @@ fun BechLink(word: String, navController: NavController) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val hashtagMatcher = hashTagsPattern.matcher(word)
|
||||
|
||||
val (tag, suffix) = try {
|
||||
hashtagMatcher.find()
|
||||
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
|
||||
} catch (e: Exception) {
|
||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
if (tag != null) {
|
||||
val hashtagIcon = checkForHashtagWithIcon(tag)
|
||||
|
||||
ClickableText(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||
) {
|
||||
append("#$tag")
|
||||
}
|
||||
},
|
||||
onClick = { navController.navigate("Hashtag/$tag") }
|
||||
)
|
||||
|
||||
if (hashtagIcon != null) {
|
||||
Icon(
|
||||
painter = painterResource(hashtagIcon.icon),
|
||||
contentDescription = hashtagIcon.description,
|
||||
tint = hashtagIcon.color,
|
||||
modifier = Modifier.size(20.dp).padding(0.dp, 5.dp, 0.dp, 0.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(text = "$suffix ")
|
||||
} else {
|
||||
Text(text = "$word ")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val matcher = tagIndex.matcher(word)
|
||||
|
||||
@@ -13,8 +13,6 @@ import coil.request.ImageRequest
|
||||
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()
|
||||
@@ -66,26 +64,20 @@ class HashImageFetcher(
|
||||
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
|
||||
)
|
||||
val source = try {
|
||||
Buffer().apply { write(svgString(data.toString()).toByteArray()) }
|
||||
} finally {
|
||||
}
|
||||
println("Elapsed: $elapsed")
|
||||
|
||||
return value
|
||||
return SourceResult(
|
||||
source = ImageSource(source, context),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.MEMORY
|
||||
)
|
||||
}
|
||||
|
||||
class Factory : Fetcher.Factory<Uri> {
|
||||
object Factory : Fetcher.Factory<Uri> {
|
||||
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||
return HashImageFetcher(options.context, data)
|
||||
}
|
||||
@@ -96,7 +88,7 @@ object Robohash {
|
||||
return ImageRequest
|
||||
.Builder(context)
|
||||
.data("robohash:$message")
|
||||
.fetcherFactory(HashImageFetcher.Factory())
|
||||
.fetcherFactory(HashImageFetcher.Factory)
|
||||
.crossfade(100)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
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.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@Composable
|
||||
fun TextSpinner(label: String, placeholder: String, options: List<String>, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
var optionsShowing by remember { mutableStateOf(false) }
|
||||
var currentText by remember { mutableStateOf(placeholder) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = currentText,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(label) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null
|
||||
) {
|
||||
optionsShowing = true
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (optionsShowing) {
|
||||
options.isNotEmpty().also {
|
||||
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
|
||||
currentText = options[it]
|
||||
optionsShowing = false
|
||||
onSelect(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
border = BorderStroke(0.25.dp, Color.LightGray),
|
||||
shape = RoundedCornerShape(5.dp)
|
||||
) {
|
||||
LazyColumn() {
|
||||
itemsIndexed(options) { index, item ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp, 16.dp)
|
||||
.clickable {
|
||||
onSelect(index)
|
||||
}
|
||||
) {
|
||||
Text(text = item, color = MaterialTheme.colors.onSurface)
|
||||
}
|
||||
if (index < options.lastIndex) {
|
||||
Divider(color = Color.LightGray, thickness = 0.25.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,18 @@ fun UrlPreviewCard(
|
||||
modifier = Modifier
|
||||
.clickable { runCatching { uri.openUri(url) } }
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
val url = URL(previewInfo.url)
|
||||
|
||||
// correctly treating relative images
|
||||
val imageUrl = if (previewInfo.image.startsWith("/")) {
|
||||
URL(URL(previewInfo.url), previewInfo.image).toString()
|
||||
URL(url, previewInfo.image).toString()
|
||||
} else {
|
||||
previewInfo.image
|
||||
}
|
||||
@@ -51,12 +57,23 @@ fun UrlPreviewCard(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Text(
|
||||
text = url.host,
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = previewInfo.title,
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
|
||||
.padding(start = 10.dp, end = 10.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
@@ -10,18 +10,41 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
object GlobalFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feed() = LocalCache.notes.values
|
||||
.filter {
|
||||
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) &&
|
||||
it.replyTo.isNullOrEmpty()
|
||||
}
|
||||
.filter {
|
||||
// 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)
|
||||
}
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
override fun feed(): List<Note> {
|
||||
val followChannels = account.followingChannels()
|
||||
val followUsers = account.followingKeySet()
|
||||
|
||||
val notes = LocalCache.notes.values
|
||||
.asSequence()
|
||||
.filter {
|
||||
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) &&
|
||||
it.replyTo.isNullOrEmpty()
|
||||
}
|
||||
.filter {
|
||||
// does not show events already in the public chat list
|
||||
(it.channel() == null || it.channel() !in followChannels) &&
|
||||
// does not show people the user already follows
|
||||
(it.author?.pubkeyHex !in followUsers)
|
||||
}
|
||||
.filter { account.isAcceptable(it) }
|
||||
.toList()
|
||||
|
||||
val longFormNotes = LocalCache.addressables.values
|
||||
.asSequence()
|
||||
.filter {
|
||||
(it.event is LongTextNoteEvent) && it.replyTo.isNullOrEmpty()
|
||||
}
|
||||
.filter {
|
||||
// does not show events already in the public chat list
|
||||
(it.channel() == null || it.channel() !in followChannels) &&
|
||||
// does not show people the user already follows
|
||||
(it.author?.pubkeyHex !in followUsers)
|
||||
}
|
||||
.filter { account.isAcceptable(it) }
|
||||
.toList()
|
||||
|
||||
return (notes + longFormNotes)
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object HashtagFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
var tag: String? = null
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val myTag = tag ?: return emptyList()
|
||||
|
||||
return LocalCache.notes.values
|
||||
.asSequence()
|
||||
.filter {
|
||||
(
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is LongTextNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is PrivateDmEvent
|
||||
) &&
|
||||
it.event?.isTaggedHash(myTag) == true
|
||||
}
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedBy { it.createdAt() }
|
||||
.toList()
|
||||
.reversed()
|
||||
}
|
||||
|
||||
fun loadHashtag(account: Account, tag: String?) {
|
||||
this.account = account
|
||||
this.tag = tag
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,15 @@ object HomeConversationsFeedFilter : FeedFilter<Note>() {
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val user = account.userProfile()
|
||||
val followingKeySet = user.cachedFollowingKeySet()
|
||||
val followingTagSet = user.cachedFollowingTagSet()
|
||||
|
||||
return LocalCache.notes.values
|
||||
.filter {
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent) &&
|
||||
it.author in user.follows &&
|
||||
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
it.author?.let { !HomeNewThreadFeedFilter.account.isHidden(it) } ?: true &&
|
||||
it.author?.let { !account.isHidden(it) } ?: true &&
|
||||
!it.isNewThread()
|
||||
}
|
||||
.sortedBy { it.createdAt() }
|
||||
|
||||
@@ -12,11 +12,13 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val user = account.userProfile()
|
||||
val followingKeySet = user.cachedFollowingKeySet()
|
||||
val followingTagSet = user.cachedFollowingTagSet()
|
||||
|
||||
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 followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
it.author?.let { !account.isHidden(it) } ?: true &&
|
||||
it.isNewThread()
|
||||
@@ -25,7 +27,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 followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
it.author?.let { !account.isHidden(it) } ?: 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()?.mapNotNull {
|
||||
LocalCache.checkGetOrCreateUser(it)
|
||||
}?.toSet()
|
||||
?.filter { account.isAcceptable(it) }
|
||||
?.reversed() ?: emptyList()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.FiltersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
|
||||
@@ -94,6 +95,16 @@ fun AppNavigation(
|
||||
})
|
||||
}
|
||||
|
||||
Route.Hashtag.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
HashtagScreen(
|
||||
tag = it.arguments?.getString("id"),
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Route.Room.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
ChatroomScreen(
|
||||
|
||||
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
@@ -138,6 +139,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
||||
NostrSingleChannelDataSource.printCounter()
|
||||
NostrSingleUserDataSource.printCounter()
|
||||
NostrThreadDataSource.printCounter()
|
||||
NostrHashtagDataSource.printCounter()
|
||||
|
||||
NostrUserProfileDataSource.printCounter()
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,12 @@ sealed class Route(
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
||||
)
|
||||
|
||||
object Hashtag : Route(
|
||||
route = "Hashtag/{id}",
|
||||
icon = R.drawable.ic_moments,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
||||
)
|
||||
|
||||
object Room : Route(
|
||||
route = "Room/{id}",
|
||||
icon = R.drawable.ic_moments,
|
||||
|
||||
@@ -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!!
|
||||
|
||||
|
||||
@@ -277,7 +277,7 @@ fun ChatroomMessageCompose(
|
||||
val eventContent = accountViewModel.decrypt(note)
|
||||
|
||||
val canPreview = note.author == accountUser ||
|
||||
(note.author?.let { accountUser.isFollowing(it) } ?: true) ||
|
||||
(note.author?.let { accountUser.isFollowingCached(it) } ?: true) ||
|
||||
!noteForReports.hasAnyReports()
|
||||
|
||||
if (eventContent != null) {
|
||||
@@ -344,7 +344,7 @@ fun ChatroomMessageCompose(
|
||||
}
|
||||
}
|
||||
|
||||
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,15 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -114,18 +118,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FlowRow() {
|
||||
multiSetCard.zapEvents.forEach {
|
||||
NoteAuthorPicture(
|
||||
note = it.key,
|
||||
navController = navController,
|
||||
userAccount = account.userProfile(),
|
||||
size = 35.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AuthorGallery(multiSetCard.zapEvents.keys, navController, account)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,18 +139,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FlowRow() {
|
||||
multiSetCard.boostEvents.forEach {
|
||||
NoteAuthorPicture(
|
||||
note = it,
|
||||
navController = navController,
|
||||
userAccount = account.userProfile(),
|
||||
size = 35.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AuthorGallery(multiSetCard.boostEvents, navController, account)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,18 +160,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FlowRow() {
|
||||
multiSetCard.likeEvents.forEach {
|
||||
NoteAuthorPicture(
|
||||
note = it,
|
||||
navController = navController,
|
||||
userAccount = account.userProfile(),
|
||||
size = 35.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AuthorGallery(multiSetCard.likeEvents, navController, account)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,3 +189,54 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthorGallery(
|
||||
authorNotes: Collection<Note>,
|
||||
navController: NavController,
|
||||
account: Account
|
||||
) {
|
||||
val accountState by account.userProfile().live().follows.observeAsState()
|
||||
val accountUser = accountState?.user ?: return
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FlowRow() {
|
||||
authorNotes.forEach {
|
||||
FastNoteAuthorPicture(
|
||||
note = it,
|
||||
navController = navController,
|
||||
userAccount = accountUser,
|
||||
size = 35.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FastNoteAuthorPicture(
|
||||
note: Note,
|
||||
navController: NavController,
|
||||
userAccount: User,
|
||||
size: Dp,
|
||||
pictureModifier: Modifier = Modifier
|
||||
) {
|
||||
// can't be null if here
|
||||
val author = note.author ?: return
|
||||
|
||||
val userState by author.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
val showFollowingMark = userAccount.isFollowingCached(user) || user == userAccount
|
||||
|
||||
UserPicture(
|
||||
userHex = user.pubkeyHex,
|
||||
userPicture = user.profilePicture(),
|
||||
showFollowingMark = showFollowingMark,
|
||||
size = size,
|
||||
modifier = pictureModifier,
|
||||
onClick = {
|
||||
navController.navigate("User/${user.pubkeyHex}")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.CutCornerShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
@@ -19,6 +21,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -32,6 +35,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.graphics.get
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
@@ -59,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
|
||||
import com.vitorpamplona.amethyst.ui.theme.Following
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -267,6 +273,15 @@ fun NoteCompose(
|
||||
)
|
||||
}
|
||||
|
||||
val firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet())
|
||||
if (firstTag != null) {
|
||||
ClickableText(
|
||||
text = AnnotatedString(" #$firstTag"),
|
||||
onClick = { navController.navigate("Hashtag/$firstTag") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
timeAgo(note.createdAt(), context = context),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
@@ -294,7 +309,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()
|
||||
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
.toSet()
|
||||
.sortedBy { account.userProfile().isFollowingCached(it) }
|
||||
|
||||
val replyingDirectlyTo = note.replyTo?.lastOrNull()
|
||||
if (replyingDirectlyTo != null && unPackReply) {
|
||||
NoteCompose(
|
||||
@@ -316,10 +336,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()
|
||||
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
.toSet()
|
||||
.sortedBy { account.userProfile().isFollowingCached(it) }
|
||||
|
||||
note.channel()?.let {
|
||||
ReplyInformationChannel(note.replyTo, sortedMentions, it, navController)
|
||||
@@ -381,14 +404,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 {
|
||||
@@ -442,7 +467,7 @@ fun NoteCompose(
|
||||
val eventContent = accountViewModel.decrypt(note)
|
||||
|
||||
val canPreview = note.author == account.userProfile() ||
|
||||
(note.author?.let { account.userProfile().isFollowing(it) } ?: true) ||
|
||||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
|
||||
!noteForReports.hasAnyReports()
|
||||
|
||||
if (eventContent != null) {
|
||||
@@ -476,17 +501,20 @@ fun NoteCompose(
|
||||
|
||||
@Composable
|
||||
fun BadgeDisplay(baseNote: Note) {
|
||||
val background = MaterialTheme.colors.background
|
||||
val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return
|
||||
var backgroundFromImage by remember { mutableStateOf(background) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.clip(shape = CutCornerShape(20, 20, 0, 0))
|
||||
.clip(shape = CutCornerShape(20, 20, 20, 20))
|
||||
.border(
|
||||
5.dp,
|
||||
MaterialTheme.colors.primary.copy(alpha = 0.32f),
|
||||
CutCornerShape(20)
|
||||
)
|
||||
.background(backgroundFromImage)
|
||||
) {
|
||||
Column {
|
||||
badgeData.image()?.let {
|
||||
@@ -497,7 +525,11 @@ fun BadgeDisplay(baseNote: Note) {
|
||||
it
|
||||
),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onSuccess = {
|
||||
val backgroundColor = it.result.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199)
|
||||
backgroundFromImage = Color(backgroundColor)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -508,7 +540,8 @@ fun BadgeDisplay(baseNote: Note) {
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
|
||||
.padding(start = 10.dp, end = 10.dp),
|
||||
color = if (backgroundFromImage.luminance() > 0.5) lightColors().onBackground else darkColors().onBackground
|
||||
)
|
||||
}
|
||||
|
||||
@@ -672,7 +705,8 @@ fun NoteAuthorPicture(
|
||||
robot = "authornotfound",
|
||||
contentDescription = stringResource(R.string.unknown_author),
|
||||
modifier = modifier
|
||||
.fillMaxSize(1f)
|
||||
.width(size)
|
||||
.height(size)
|
||||
.clip(shape = CircleShape)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
@@ -695,7 +729,6 @@ fun UserPicture(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun UserPicture(
|
||||
baseUser: User,
|
||||
@@ -708,24 +741,52 @@ fun UserPicture(
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
val accountState by baseUserAccount.live().follows.observeAsState()
|
||||
val accountUser = accountState?.user ?: return
|
||||
|
||||
val showFollowingMark = accountUser.isFollowingCached(user) || user == accountUser
|
||||
|
||||
UserPicture(
|
||||
userHex = user.pubkeyHex,
|
||||
userPicture = user.profilePicture(),
|
||||
showFollowingMark = showFollowingMark,
|
||||
size = size,
|
||||
modifier = modifier,
|
||||
onClick = onClick?.let { { it(user) } },
|
||||
onLongClick = onLongClick?.let { { it(user) } }
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun UserPicture(
|
||||
userHex: String,
|
||||
userPicture: String?,
|
||||
showFollowingMark: Boolean,
|
||||
size: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onLongClick: (() -> Unit)? = null
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(size)
|
||||
.height(size)
|
||||
) {
|
||||
RobohashAsyncImageProxy(
|
||||
robot = user.pubkeyHex,
|
||||
model = ResizeImage(user.profilePicture(), size),
|
||||
robot = userHex,
|
||||
model = ResizeImage(userPicture, size),
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = modifier
|
||||
.fillMaxSize(1f)
|
||||
.width(size)
|
||||
.height(size)
|
||||
.clip(shape = CircleShape)
|
||||
.background(MaterialTheme.colors.background)
|
||||
.run {
|
||||
if (onClick != null && onLongClick != null) {
|
||||
this.combinedClickable(onClick = { onClick(user) }, onLongClick = { onLongClick(user) })
|
||||
this.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
} else if (onClick != null) {
|
||||
this.clickable(onClick = { onClick(user) })
|
||||
this.clickable(onClick = onClick)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
@@ -733,10 +794,7 @@ fun UserPicture(
|
||||
|
||||
)
|
||||
|
||||
val accountState by baseUserAccount.live().follows.observeAsState()
|
||||
val accountUser = accountState?.user ?: return
|
||||
|
||||
if (accountUser.isFollowing(user) || user == accountUser) {
|
||||
if (showFollowingMark) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(size.div(3.5f))
|
||||
@@ -769,12 +827,13 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val appContext = LocalContext.current.applicationContext
|
||||
val actContext = LocalContext.current
|
||||
var reportDialogShowing by remember { mutableStateOf(false) }
|
||||
|
||||
DropdownMenu(
|
||||
expanded = popupExpanded,
|
||||
onDismissRequest = onDismiss
|
||||
) {
|
||||
if (note.author != accountViewModel.accountLiveData.value?.account?.userProfile() && !accountViewModel.accountLiveData.value?.account?.userProfile()!!.isFollowing(note.author!!)) {
|
||||
if (!accountViewModel.isFollowing(note.author)) {
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.follow(
|
||||
note.author ?: return@DropdownMenuItem
|
||||
@@ -814,57 +873,22 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
|
||||
DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) {
|
||||
Text(stringResource(R.string.broadcast))
|
||||
}
|
||||
if (note.author == accountViewModel.accountLiveData.value?.account?.userProfile()) {
|
||||
Divider()
|
||||
Divider()
|
||||
if (accountViewModel.isLoggedUser(note.author)) {
|
||||
DropdownMenuItem(onClick = { accountViewModel.delete(note); onDismiss() }) {
|
||||
Text(stringResource(R.string.request_deletion))
|
||||
}
|
||||
}
|
||||
if (note.author != accountViewModel.accountLiveData.value?.account?.userProfile()) {
|
||||
Divider()
|
||||
DropdownMenuItem(onClick = {
|
||||
note.author?.let {
|
||||
accountViewModel.hide(it)
|
||||
}; onDismiss()
|
||||
}) {
|
||||
Text(stringResource(R.string.block_hide_user))
|
||||
}
|
||||
Divider()
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(note, ReportEvent.ReportType.SPAM)
|
||||
note.author?.let { accountViewModel.hide(it) }
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(R.string.report_spam_scam))
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(note, ReportEvent.ReportType.PROFANITY)
|
||||
note.author?.let { accountViewModel.hide(it) }
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(R.string.report_hateful_speech))
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(note, ReportEvent.ReportType.IMPERSONATION)
|
||||
note.author?.let { accountViewModel.hide(it) }
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(R.string.report_impersonation))
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(note, ReportEvent.ReportType.NUDITY)
|
||||
note.author?.let { accountViewModel.hide(it) }
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(R.string.report_nudity_porn))
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(note, ReportEvent.ReportType.ILLEGAL)
|
||||
note.author?.let { accountViewModel.hide(it) }
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(R.string.report_illegal_behaviour))
|
||||
} else {
|
||||
DropdownMenuItem(onClick = { reportDialogShowing = true }) {
|
||||
Text("Block / Report")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reportDialogShowing) {
|
||||
ReportNoteDialog(note = note, accountViewModel = accountViewModel) {
|
||||
reportDialogShowing = false
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -16,6 +17,8 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonColors
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
@@ -24,11 +27,13 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AlternateEmail
|
||||
import androidx.compose.material.icons.filled.Block
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FormatQuote
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.PersonRemove
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -40,6 +45,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
@@ -57,9 +63,11 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
|
||||
import com.vitorpamplona.amethyst.ui.theme.WarningColor
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
fun lightenColor(color: Color, amount: Float): Color {
|
||||
private fun lightenColor(color: Color, amount: Float): Color {
|
||||
var argb = color.toArgb()
|
||||
val hslOut = floatArrayOf(0f, 0f, 0f)
|
||||
ColorUtils.colorToHSL(argb, hslOut)
|
||||
@@ -71,7 +79,7 @@ fun lightenColor(color: Color, amount: Float): Color {
|
||||
val externalLinkForNote = { note: Note -> "https://snort.social/e/${note.idNote()}" }
|
||||
|
||||
@Composable
|
||||
fun VerticalDivider(color: Color) =
|
||||
private fun VerticalDivider(color: Color) =
|
||||
Divider(
|
||||
color = color,
|
||||
modifier = Modifier
|
||||
@@ -88,9 +96,17 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
|
||||
val scope = rememberCoroutineScope()
|
||||
var showSelectTextDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteAlertDialog by remember { mutableStateOf(false) }
|
||||
val isOwnNote = note.author == accountViewModel.userProfile()
|
||||
var showBlockAlertDialog by remember { mutableStateOf(false) }
|
||||
var showReportDialog by remember { mutableStateOf(false) }
|
||||
val isOwnNote = accountViewModel.isLoggedUser(note.author)
|
||||
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!)
|
||||
|
||||
val backgroundColor = if (MaterialTheme.colors.isLight) {
|
||||
MaterialTheme.colors.primary
|
||||
} else {
|
||||
MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background)
|
||||
}
|
||||
|
||||
val showToast = { stringResource: Int ->
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
@@ -106,7 +122,7 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
|
||||
Card(
|
||||
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape),
|
||||
shape = cardShape,
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
backgroundColor = backgroundColor
|
||||
) {
|
||||
Column(modifier = Modifier.width(IntrinsicSize.Min)) {
|
||||
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||
@@ -134,6 +150,19 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
|
||||
showToast(R.string.copied_note_id_to_clipboard)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
if (!isOwnNote) {
|
||||
VerticalDivider(primaryLight)
|
||||
|
||||
NoteQuickActionItem(Icons.Default.Block, stringResource(R.string.quick_action_block)) {
|
||||
if (accountViewModel.hideBlockAlertDialog) {
|
||||
note.author?.let { accountViewModel.hide(it) }
|
||||
onDismiss()
|
||||
} else {
|
||||
showBlockAlertDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider(
|
||||
color = primaryLight,
|
||||
@@ -144,7 +173,7 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
|
||||
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||
if (isOwnNote) {
|
||||
NoteQuickActionItem(Icons.Default.Delete, stringResource(R.string.quick_action_delete)) {
|
||||
if (accountViewModel.hideDeleteRequestInfo()) {
|
||||
if (accountViewModel.hideDeleteRequestDialog) {
|
||||
accountViewModel.delete(note)
|
||||
onDismiss()
|
||||
} else {
|
||||
@@ -187,6 +216,14 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
|
||||
ContextCompat.startActivity(context, shareIntent, null)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
if (!isOwnNote) {
|
||||
VerticalDivider(primaryLight)
|
||||
|
||||
NoteQuickActionItem(Icons.Default.Report, stringResource(R.string.quick_action_report)) {
|
||||
showReportDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,36 +237,24 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
|
||||
}
|
||||
|
||||
if (showDeleteAlertDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { onDismiss() },
|
||||
title = {
|
||||
Text(text = stringResource(R.string.quick_action_request_deletion_alert_title))
|
||||
},
|
||||
text = {
|
||||
Text(text = stringResource(R.string.quick_action_request_deletion_alert_body))
|
||||
},
|
||||
buttons = {
|
||||
Row(
|
||||
modifier = Modifier.padding(all = 8.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
accountViewModel.setHideDeleteRequestInfo()
|
||||
accountViewModel.delete(note)
|
||||
onDismiss()
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.quick_action_dont_show_again_button))
|
||||
}
|
||||
Button(
|
||||
onClick = { accountViewModel.delete(note); onDismiss() }
|
||||
) {
|
||||
Text(stringResource(R.string.quick_action_delete_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
DeleteAlertDialog(note, accountViewModel) {
|
||||
showDeleteAlertDialog = false
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
if (showBlockAlertDialog) {
|
||||
BlockAlertDialog(note, accountViewModel) {
|
||||
showBlockAlertDialog = false
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
if (showReportDialog) {
|
||||
ReportNoteDialog(note, accountViewModel) {
|
||||
showReportDialog = false
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,9 +270,99 @@ fun NoteQuickActionItem(icon: ImageVector, label: String, onClick: () -> Unit) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp).padding(bottom = 5.dp),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.padding(bottom = 5.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
Text(text = label, fontSize = 12.sp, color = Color.White, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) =
|
||||
QuickActionAlertDialog(
|
||||
title = stringResource(R.string.quick_action_request_deletion_alert_title),
|
||||
textContent = stringResource(R.string.quick_action_request_deletion_alert_body),
|
||||
buttonIcon = Icons.Default.Delete,
|
||||
buttonText = stringResource(R.string.quick_action_delete_dialog_btn),
|
||||
onClickDoOnce = {
|
||||
accountViewModel.delete(note)
|
||||
onDismiss()
|
||||
},
|
||||
onClickDontShowAgain = {
|
||||
accountViewModel.delete(note)
|
||||
accountViewModel.dontShowDeleteRequestDialog()
|
||||
onDismiss()
|
||||
},
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun BlockAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) =
|
||||
QuickActionAlertDialog(
|
||||
title = stringResource(R.string.report_dialog_block_hide_user_btn),
|
||||
textContent = stringResource(R.string.report_dialog_blocking_a_user),
|
||||
buttonIcon = Icons.Default.Block,
|
||||
buttonText = stringResource(R.string.quick_action_block_dialog_btn),
|
||||
buttonColors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = WarningColor,
|
||||
contentColor = Color.White
|
||||
),
|
||||
onClickDoOnce = {
|
||||
note.author?.let { accountViewModel.hide(it) }
|
||||
onDismiss()
|
||||
},
|
||||
onClickDontShowAgain = {
|
||||
note.author?.let { accountViewModel.hide(it) }
|
||||
accountViewModel.dontShowBlockAlertDialog()
|
||||
onDismiss()
|
||||
},
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun QuickActionAlertDialog(
|
||||
title: String,
|
||||
textContent: String,
|
||||
buttonIcon: ImageVector,
|
||||
buttonText: String,
|
||||
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
|
||||
onClickDoOnce: () -> Unit,
|
||||
onClickDontShowAgain: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(title)
|
||||
},
|
||||
text = {
|
||||
Text(textContent)
|
||||
},
|
||||
buttons = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(all = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = onClickDontShowAgain) {
|
||||
Text(stringResource(R.string.quick_action_dont_show_again_button))
|
||||
}
|
||||
Button(onClick = onClickDoOnce, colors = buttonColors) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = buttonIcon,
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(buttonText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, account: Accou
|
||||
|
||||
@Composable
|
||||
fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Account, prefix: String = "", onUserTagClick: (User) -> Unit) {
|
||||
val mentions = dupMentions?.toSet()?.sortedBy { !account.userProfile().isFollowing(it) }
|
||||
val mentions = dupMentions?.toSet()?.sortedBy { !account.userProfile().isFollowingCached(it) }
|
||||
var expanded by remember { mutableStateOf((mentions?.size ?: 0) <= 2) }
|
||||
|
||||
FlowRow() {
|
||||
|
||||
@@ -72,7 +72,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
|
||||
ShowUserButton {
|
||||
account.showUser(baseUser.pubkeyHex)
|
||||
}
|
||||
} else if (userFollows.isFollowing(baseUser)) {
|
||||
} else if (userFollows.isFollowingCached(baseUser)) {
|
||||
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
|
||||
} else {
|
||||
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }
|
||||
|
||||
@@ -117,7 +117,7 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
|
||||
ShowUserButton {
|
||||
account.showUser(baseAuthor.pubkeyHex)
|
||||
}
|
||||
} else if (userFollows.isFollowing(baseAuthor)) {
|
||||
} else if (userFollows.isFollowingCached(baseAuthor)) {
|
||||
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseAuthor) } }
|
||||
} else {
|
||||
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(baseAuthor) } }
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
|
||||
@@ -33,6 +34,7 @@ class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter)
|
||||
class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter)
|
||||
class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter)
|
||||
class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter)
|
||||
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
|
||||
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)
|
||||
class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter)
|
||||
class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -328,7 +328,7 @@ fun NoteMaster(
|
||||
val eventContent = note.event?.content()
|
||||
|
||||
val canPreview = note.author == account.userProfile() ||
|
||||
(note.author?.let { account.userProfile().isFollowing(it) } ?: true) ||
|
||||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
|
||||
!noteForReports.hasAnyReports()
|
||||
|
||||
if (eventContent != null) {
|
||||
|
||||
@@ -73,8 +73,8 @@ class AccountViewModel(private val account: Account) : ViewModel() {
|
||||
)
|
||||
}
|
||||
|
||||
fun report(note: Note, type: ReportEvent.ReportType) {
|
||||
account.report(note, type)
|
||||
fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
|
||||
account.report(note, type, content)
|
||||
}
|
||||
|
||||
fun report(user: User, type: ReportEvent.ReportType) {
|
||||
@@ -125,15 +125,26 @@ class AccountViewModel(private val account: Account) : ViewModel() {
|
||||
account.unfollow(user)
|
||||
}
|
||||
|
||||
fun isFollowing(user: User): Boolean {
|
||||
return account.userProfile().isFollowing(user)
|
||||
fun isLoggedUser(user: User?): Boolean {
|
||||
return account.userProfile() == user
|
||||
}
|
||||
|
||||
fun hideDeleteRequestInfo(): Boolean {
|
||||
return account.hideDeleteRequestInfo
|
||||
fun isFollowing(user: User?): Boolean {
|
||||
if (user == null) return false
|
||||
return account.userProfile().isFollowingCached(user)
|
||||
}
|
||||
|
||||
fun setHideDeleteRequestInfo() {
|
||||
account.setHideDeleteRequestInfo()
|
||||
val hideDeleteRequestDialog: Boolean
|
||||
get() = account.hideDeleteRequestDialog
|
||||
|
||||
fun dontShowDeleteRequestDialog() {
|
||||
account.setHideDeleteRequestDialog()
|
||||
}
|
||||
|
||||
val hideBlockAlertDialog: Boolean
|
||||
get() = account.hideBlockAlertDialog
|
||||
|
||||
fun dontShowBlockAlertDialog() {
|
||||
account.setHideBlockAlertDialog()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Divider
|
||||
@@ -35,7 +33,6 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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
|
||||
@@ -56,10 +53,9 @@ 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
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.ChatroomFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatRoomFeedViewModel
|
||||
@@ -108,7 +104,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
NostrChatroomDataSource.withUser?.let {
|
||||
ChatroomHeader(it, navController = navController)
|
||||
ChatroomHeader(it, account.userProfile(), navController = navController)
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -210,7 +206,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatroomHeader(baseUser: User, navController: NavController) {
|
||||
fun ChatroomHeader(baseUser: User, accountUser: User, navController: NavController) {
|
||||
Column(
|
||||
modifier = Modifier.clickable(
|
||||
onClick = { navController.navigate("User/${baseUser.pubkeyHex}") }
|
||||
@@ -218,17 +214,10 @@ fun ChatroomHeader(baseUser: User, navController: NavController) {
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val authorState by baseUser.live().metadata.observeAsState()
|
||||
val author = authorState?.user!!
|
||||
|
||||
RobohashAsyncImageProxy(
|
||||
robot = author.pubkeyHex,
|
||||
model = ResizeImage(author.profilePicture(), 35.dp),
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(35.dp)
|
||||
.height(35.dp)
|
||||
.clip(shape = CircleShape)
|
||||
UserPicture(
|
||||
baseUser = baseUser,
|
||||
baseUserAccount = accountUser,
|
||||
size = 35.dp
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Divider
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
|
||||
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
if (tag != null) {
|
||||
val feedViewModel: NostrHashtagFeedViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(tag) {
|
||||
HashtagFeedFilter.loadHashtag(account, tag)
|
||||
NostrHashtagDataSource.loadHashtag(tag)
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Hashtag Start")
|
||||
HashtagFeedFilter.loadHashtag(account, tag)
|
||||
NostrHashtagDataSource.loadHashtag(tag)
|
||||
NostrHashtagDataSource.start()
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
println("Hashtag Stop")
|
||||
HashtagFeedFilter.loadHashtag(account, null)
|
||||
NostrHashtagDataSource.loadHashtag(null)
|
||||
NostrHashtagDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
lifeCycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifeCycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
HashtagHeader(tag, account)
|
||||
FeedView(feedViewModel, accountViewModel, navController, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HashtagHeader(tag: String, account: Account) {
|
||||
val userState by account.userProfile().live().follows.observeAsState()
|
||||
val userFollows = userState?.user ?: return
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column() {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
"#$tag",
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
if (userFollows.isFollowingHashtagCached(tag)) {
|
||||
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(tag) } }
|
||||
} else {
|
||||
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(tag) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)}")
|
||||
}
|
||||
@@ -373,7 +373,7 @@ private fun ProfileHeader(
|
||||
ShowUserButton {
|
||||
account.showUser(baseUser.pubkeyHex)
|
||||
}
|
||||
} else if (accountUser.isFollowing(baseUser)) {
|
||||
} else if (accountUser.isFollowingCached(baseUser)) {
|
||||
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
|
||||
} else {
|
||||
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }
|
||||
@@ -457,7 +457,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, navController:
|
||||
val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull()
|
||||
|
||||
if (baseBadgeDefinition != null) {
|
||||
BadgeThumb(baseBadgeDefinition, navController, 50.dp)
|
||||
BadgeThumb(baseBadgeDefinition, navController, 35.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -585,7 +585,8 @@ fun BadgeThumb(
|
||||
robot = "authornotfound",
|
||||
contentDescription = stringResource(R.string.unknown_author),
|
||||
modifier = pictureModifier
|
||||
.fillMaxSize(1f)
|
||||
.width(size)
|
||||
.height(size)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
} else {
|
||||
@@ -594,7 +595,8 @@ fun BadgeThumb(
|
||||
model = image,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = pictureModifier
|
||||
.fillMaxSize(1f)
|
||||
.width(size)
|
||||
.height(size)
|
||||
.clip(shape = CircleShape)
|
||||
.background(MaterialTheme.colors.background)
|
||||
.run {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Block
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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 com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.ui.theme.WarningColor
|
||||
|
||||
@Composable
|
||||
fun ReportNoteDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) {
|
||||
val reportTypes = listOf(
|
||||
Pair(ReportEvent.ReportType.SPAM, stringResource(R.string.report_dialog_spam)),
|
||||
Pair(ReportEvent.ReportType.PROFANITY, stringResource(R.string.report_dialog_profanity)),
|
||||
Pair(ReportEvent.ReportType.IMPERSONATION, stringResource(R.string.report_dialog_impersonation)),
|
||||
Pair(ReportEvent.ReportType.NUDITY, stringResource(R.string.report_dialog_nudity)),
|
||||
Pair(ReportEvent.ReportType.ILLEGAL, stringResource(R.string.report_dialog_illegal))
|
||||
)
|
||||
|
||||
val reasonOptions = reportTypes.map { it.second }
|
||||
var additionalReason by remember { mutableStateOf("") }
|
||||
var selectedReason by remember { mutableStateOf(-1) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = "Block and Report") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back),
|
||||
tint = MaterialTheme.colors.onSurface
|
||||
)
|
||||
}
|
||||
},
|
||||
backgroundColor = MaterialTheme.colors.surface,
|
||||
elevation = 0.dp
|
||||
)
|
||||
}
|
||||
) { pad ->
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp, pad.calculateTopPadding(), 16.dp, pad.calculateBottomPadding()),
|
||||
verticalArrangement = Arrangement.SpaceAround
|
||||
) {
|
||||
SpacerH16()
|
||||
SectionHeader(text = "Block")
|
||||
SpacerH16()
|
||||
Text(
|
||||
text = stringResource(R.string.report_dialog_blocking_a_user)
|
||||
)
|
||||
SpacerH16()
|
||||
ActionButton(
|
||||
text = stringResource(R.string.report_dialog_block_hide_user_btn),
|
||||
icon = Icons.Default.Block,
|
||||
onClick = {
|
||||
note.author?.let { accountViewModel.hide(it) }
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
SpacerH16()
|
||||
|
||||
Divider(color = MaterialTheme.colors.onSurface, thickness = 0.25.dp)
|
||||
|
||||
SpacerH16()
|
||||
SectionHeader(text = stringResource(R.string.report_dialog_report_btn))
|
||||
SpacerH16()
|
||||
Text(stringResource(R.string.report_dialog_reminder_public))
|
||||
SpacerH16()
|
||||
TextSpinner(
|
||||
label = stringResource(R.string.report_dialog_select_reason_label),
|
||||
placeholder = stringResource(R.string.report_dialog_select_reason_placeholder),
|
||||
options = reasonOptions,
|
||||
onSelect = {
|
||||
selectedReason = it
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
SpacerH16()
|
||||
OutlinedTextField(
|
||||
value = additionalReason,
|
||||
onValueChange = { additionalReason = it },
|
||||
placeholder = { Text(text = stringResource(R.string.report_dialog_additional_reason_placeholder)) },
|
||||
label = { Text(stringResource(R.string.report_dialog_additional_reason_label)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
SpacerH16()
|
||||
ActionButton(
|
||||
text = stringResource(R.string.report_dialog_post_report_btn),
|
||||
icon = Icons.Default.Report,
|
||||
enabled = selectedReason in 0..reportTypes.lastIndex,
|
||||
onClick = {
|
||||
accountViewModel.report(note, reportTypes[selectedReason].first, additionalReason)
|
||||
note.author?.let { accountViewModel.hide(it) }
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpacerH16() = Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) = Text(
|
||||
text = text,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.onSurface,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ActionButton(text: String, icon: ImageVector, enabled: Boolean = true, onClick: () -> Unit) = Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = WarningColor),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = text, color = Color.White)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Divider
|
||||
@@ -72,6 +73,7 @@ import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.regex.Pattern
|
||||
import kotlinx.coroutines.channels.Channel as CoroutineChannel
|
||||
|
||||
@Composable
|
||||
@@ -95,10 +97,13 @@ fun SearchScreen(
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Global Start")
|
||||
NostrGlobalDataSource.start()
|
||||
NostrSearchEventOrUserDataSource.start()
|
||||
feedViewModel.refresh()
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
println("Global Stop")
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
NostrSearchEventOrUserDataSource.stop()
|
||||
NostrGlobalDataSource.stop()
|
||||
}
|
||||
}
|
||||
@@ -126,7 +131,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
|
||||
val searchResults = remember { mutableStateOf<List<User>>(emptyList()) }
|
||||
val searchResultsNotes = remember { mutableStateOf<List<Note>>(emptyList()) }
|
||||
val searchResultsChannels = remember { mutableStateOf<List<Channel>>(emptyList()) }
|
||||
val hashtagResults = remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val onlineSearch = NostrSearchEventOrUserDataSource
|
||||
|
||||
@@ -149,6 +156,8 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
|
||||
.distinctUntilChanged()
|
||||
.debounce(300)
|
||||
.collectLatest {
|
||||
hashtagResults.value = findHashtags(it)
|
||||
|
||||
if (it.removePrefix("npub").removePrefix("note").length >= 4) {
|
||||
onlineSearch.search(it.trim())
|
||||
}
|
||||
@@ -156,6 +165,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
|
||||
searchResults.value = LocalCache.findUsersStartingWith(it)
|
||||
searchResultsNotes.value = LocalCache.findNotesStartingWith(it).sortedBy { it.createdAt() }.reversed()
|
||||
searchResultsChannels.value = LocalCache.findChannelsStartingWith(it)
|
||||
|
||||
// makes sure to show the top of the search
|
||||
scope.launch(Dispatchers.Main) { listState.animateScrollToItem(0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,8 +248,15 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
)
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(hashtagResults.value, key = { _, item -> "#" + item }) { _, item ->
|
||||
HashtagLine(item) {
|
||||
navController.navigate("Hashtag/$item")
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item ->
|
||||
UserCompose(item, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
@@ -266,6 +285,57 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
|
||||
}
|
||||
}
|
||||
|
||||
val hashtagSearch = Pattern.compile("(?:\\s|\\A)#([A-Za-z0-9_\\-]+)")
|
||||
|
||||
fun findHashtags(content: String): List<String> {
|
||||
val matcher = hashtagSearch.matcher(content)
|
||||
val returningList = mutableSetOf<String>()
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)
|
||||
if (tag != null && tag.isNotBlank()) {
|
||||
returningList.add(tag)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
return returningList.toList()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HashtagLine(tag: String, onClick: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
"Search hashtag: #$tag",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserLine(
|
||||
baseUser: User,
|
||||
|
||||
@@ -12,3 +12,5 @@ val Following = Color(0xFF03DAC5)
|
||||
val Nip05 = Color(0xFF01BAFF)
|
||||
val FollowsFollow = Color.Yellow
|
||||
val NIP05Verified = Color.Blue
|
||||
|
||||
val WarningColor = Color(0xFFC62828)
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -56,7 +56,6 @@
|
||||
<string name="thank_you_so_much">شكرا جزيلا!</string>
|
||||
<string name="amount_in_sats">المبلغ في Sats</string>
|
||||
<string name="send_sats">إرسال Sats</string>
|
||||
<string name="never_translate_from">"لا تترجم ابداً من "</string>
|
||||
<string name="error_parsing_preview_for">خطأ في تحليل المعاينة لـ %1$s : %2$s</string>
|
||||
<string name="preview_card_image_for">معاينة صورة البطاقة لـ %1$s</string>
|
||||
<string name="new_channel">قناة جديدة</string>
|
||||
@@ -153,12 +152,12 @@
|
||||
<string name="public_chat">الدردشة العامة</string>
|
||||
<string name="posts_received">تم استلام المشاركات</string>
|
||||
<string name="remove">ازالة</string>
|
||||
<string name="auto">تلقائي</string>
|
||||
<string name="translated_from">مترجم من</string>
|
||||
<string name="to">الى</string>
|
||||
<string name="show_in">تظهر في</string>
|
||||
<string name="first">اولا</string>
|
||||
<string name="always_translate_to">ترجمة إلى</string>
|
||||
<string name="translations_auto">تلقائي</string>
|
||||
<string name="translations_translated_from">مترجم من</string>
|
||||
<string name="translations_to">الى</string>
|
||||
<string name="translations_show_in_lang_first">تظهر في %1$s اولا</string>
|
||||
<string name="translations_always_translate_to_lang">ترجمة إلى %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">لا تترجم ابداً من %1$s</string>
|
||||
<string name="never">ابداً</string>
|
||||
<string name="now">الان</string>
|
||||
<string name="h">س</string>
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
<string name="thank_you_so_much">¡Muchas gracias!</string>
|
||||
<string name="amount_in_sats">Cantidad en Sats</string>
|
||||
<string name="send_sats">Enviar Sats</string>
|
||||
<string name="never_translate_from">"Nunca traducir desde "</string>
|
||||
<string name="error_parsing_preview_for">"Error al analizar la vista previa de %1$s : %2$s"</string>
|
||||
<string name="preview_card_image_for">"Imagen de vista previa para %1$s"</string>
|
||||
<string name="new_channel">Nuevo canal</string>
|
||||
@@ -169,12 +168,12 @@
|
||||
<string name="public_chat">Chat público</string>
|
||||
<string name="posts_received">publicaciones recibidas</string>
|
||||
<string name="remove">Eliminar</string>
|
||||
<string name="auto">Auto</string>
|
||||
<string name="translated_from">traducido de</string>
|
||||
<string name="to">a</string>
|
||||
<string name="show_in">Mostrar en</string>
|
||||
<string name="first">primero</string>
|
||||
<string name="always_translate_to">"Traducir siempre a "</string>
|
||||
<string name="translations_auto">Auto</string>
|
||||
<string name="translations_translated_from">traducido de</string>
|
||||
<string name="translations_to">a</string>
|
||||
<string name="translations_show_in_lang_first">Mostrar en %1$s primero</string>
|
||||
<string name="translations_always_translate_to_lang">Traducir siempre a %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">Nunca traducir desde %1$s</string>
|
||||
<string name="never">nunca</string>
|
||||
<string name="now">ahora</string>
|
||||
<string name="h">h</string>
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
<string name="thank_you_so_much">Merci beaucoup!</string>
|
||||
<string name="amount_in_sats">Montant en Sats</string>
|
||||
<string name="send_sats">Envoyer des Sats</string>
|
||||
<string name="never_translate_from">"Ne jamais traduire depuis "</string>
|
||||
<string name="error_parsing_preview_for">"Erreur d\'analyse de l\'aperçu pour %1$s : %2$s"</string>
|
||||
<string name="preview_card_image_for">"Aperçu de l\'image pour %1$s"</string>
|
||||
<string name="new_channel">Nouveau canal</string>
|
||||
@@ -154,12 +153,12 @@
|
||||
<string name="public_chat">Chat public</string>
|
||||
<string name="posts_received">messages reçus</string>
|
||||
<string name="remove">Retirer</string>
|
||||
<string name="auto">Auto</string>
|
||||
<string name="translated_from">traduit de</string>
|
||||
<string name="to">vers</string>
|
||||
<string name="show_in">Montre en</string>
|
||||
<string name="first">en premier</string>
|
||||
<string name="always_translate_to">"Toujours traduire en "</string>
|
||||
<string name="translations_auto">Auto</string>
|
||||
<string name="translations_translated_from">traduit de</string>
|
||||
<string name="translations_to">vers</string>
|
||||
<string name="translations_show_in_lang_first">Montre en %1$s en premier</string>
|
||||
<string name="translations_always_translate_to_lang">Toujours traduire en %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">Ne jamais traduire depuis %1$s</string>
|
||||
<string name="never">jamais</string>
|
||||
<string name="now">maintenant</string>
|
||||
<string name="h">h</string>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<string name="copy_user_pubkey">A felhasználó PubKulcs másolása</string>
|
||||
<string name="copy_note_id">A bejegyzés azonosítójának másolása</string>
|
||||
<string name="broadcast">Közvetít</string>
|
||||
<string name="block_hide_user"><![CDATA[Block & Hide User]]></string>
|
||||
<string name="block_hide_user"><![CDATA[Tiltás és Felhasználó elrejtése]]></string>
|
||||
<string name="report_spam_scam">Spam / Csalás bejelentés</string>
|
||||
<string name="report_impersonation">Megszemélyesítés bejelentés</string>
|
||||
<string name="report_explicit_content">Szókimondó tartalom bejelentés</string>
|
||||
@@ -56,7 +56,6 @@
|
||||
<string name="thank_you_so_much">Nagyon szépen köszönöm!</string>
|
||||
<string name="amount_in_sats">Összeg Sats-ban</string>
|
||||
<string name="send_sats">Sats küldése</string>
|
||||
<string name="never_translate_from">"Soha ne fordítsd erről "</string>
|
||||
<string name="error_parsing_preview_for">"Hiba a következő előnézetében %1$s : %2$s"</string>
|
||||
<string name="preview_card_image_for">"Kártyakép előnézete %1$s"</string>
|
||||
<string name="new_channel">Új Csatorna</string>
|
||||
@@ -66,7 +65,7 @@
|
||||
<string name="description">Leírás</string>
|
||||
<string name="about_us">"Rólunk.. "</string>
|
||||
<string name="what_s_on_your_mind">Mire gondolsz?</string>
|
||||
<string name="post">Hozzászólás</string>
|
||||
<string name="post">Küldés</string>
|
||||
<string name="save">Mentés</string>
|
||||
<string name="create">Létrehoz</string>
|
||||
<string name="cancel">Törlés</string>
|
||||
@@ -86,7 +85,7 @@
|
||||
<string name="my_username">Felhasználónevem</string>
|
||||
<string name="about_me">Rólam</string>
|
||||
<string name="avatar_url">Avatarom URL-je</string>
|
||||
<string name="banner_url">Bannerem UR-jeL</string>
|
||||
<string name="banner_url">Bannerem URL-je</string>
|
||||
<string name="website_url">Oldalam URL-je</string>
|
||||
<string name="ln_address">LN Cím</string>
|
||||
<string name="ln_url_outdated">LN URL (elavult)</string>
|
||||
@@ -95,7 +94,7 @@
|
||||
<string name="upload_image">Kép feltöltése</string>
|
||||
<string name="uploading">Feltöltés…</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">A felhasználó a sat fogadáshoz nem rendelkezik LN cím beállítással</string>
|
||||
<string name="reply_here">"válaszolj ide.. "</string>
|
||||
<string name="reply_here">"itt tudsz válaszolni.. "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Megosztáshoz a bejegyzés azonosítót a vágólapra másolja</string>
|
||||
<string name="copy_channel_id_note_to_the_clipboard">Bejegyzés azonosító vágólapra másolása</string>
|
||||
<string name="edits_the_channel_metadata">Szerkeszti a csatorna metaadatait</string>
|
||||
@@ -103,7 +102,7 @@
|
||||
<string name="known">Ismert</string>
|
||||
<string name="new_requests">Új kérések</string>
|
||||
<string name="blocked_users">Letiltott felhasználók</string>
|
||||
<string name="new_threads">Új szálak</string>
|
||||
<string name="new_threads">Bejegyzések</string>
|
||||
<string name="conversations">Beszélgetések</string>
|
||||
<string name="notes">Bejegyzések</string>
|
||||
<string name="replies">Válaszok</string>
|
||||
@@ -131,7 +130,7 @@
|
||||
<string name="hide_password">Jelszó elrejtése</string>
|
||||
<string name="invalid_key">Érvénytelen kulcs</string>
|
||||
<string name="i_accept_the">"Elfogadom a "</string>
|
||||
<string name="terms_of_use">használati feltételek</string>
|
||||
<string name="terms_of_use">használati feltételeket</string>
|
||||
<string name="acceptance_of_terms_is_required">A feltételek elfogadása szükséges</string>
|
||||
<string name="key_is_required">Kulcs szükséges</string>
|
||||
<string name="login">Bejelentkezés</string>
|
||||
@@ -148,18 +147,18 @@
|
||||
<string name="description_to">leírását erre</string>
|
||||
<string name="and_picture_to">és a képet erre</string>
|
||||
<string name="leave">Kilépés</string>
|
||||
<string name="unfollow">Leíratkozás</string>
|
||||
<string name="unfollow">Kikövetem</string>
|
||||
<string name="channel_created">Csatorna létrehozva</string>
|
||||
<string name="channel_information_changed_to">"A csatornainformáció a következőre módosult"</string>
|
||||
<string name="public_chat">Publikus Chat</string>
|
||||
<string name="posts_received">beérkezett hozzászólások</string>
|
||||
<string name="remove">Eltávolítás</string>
|
||||
<string name="auto">Automatikus</string>
|
||||
<string name="translated_from">fordítás erről</string>
|
||||
<string name="to">erre</string>
|
||||
<string name="show_in">Mutasd ebben</string>
|
||||
<string name="first">első</string>
|
||||
<string name="always_translate_to">Mindig fordítsa le</string>
|
||||
<string name="translations_auto">Automatikus</string>
|
||||
<string name="translations_translated_from">fordítás erről</string>
|
||||
<string name="translations_to">erre</string>
|
||||
<string name="translations_show_in_lang_first">Először %1$s nyelven</string>
|
||||
<string name="translations_always_translate_to_lang">Mindig fordítsa le %1$s-ra</string>
|
||||
<string name="translations_never_translate_from_lang">Soha ne fordíts %1$s-ról</string>
|
||||
<string name="never">soha</string>
|
||||
<string name="now">most</string>
|
||||
<string name="h">ó</string>
|
||||
@@ -175,7 +174,7 @@
|
||||
<string name="mark_all_as_read">Összes megjelölése olvasottként</string>
|
||||
<string name="account_backup_tips_md">
|
||||
## Kulcs- és biztonsági mentési tippek
|
||||
\n\nFiókját titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-al kezdődik. Bárki, aki hozzáfér az Ön titkos kulcsához, közzétehet tartalmat az Ön személyazonosságának használatával.
|
||||
\n\nFiókját titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-el kezdődik. Bárki, aki az Ön titkos kulcsához hozzáfér, az Ön személyazonosságának használatával bármilyen tartalmat közzétehet.
|
||||
\n\n- **Ne** helyezze el titkos kulcsát olyan webhelyen vagy szoftverben, amelyben nem bízik.
|
||||
\n- Az Amethyst fejlesztők a titkos kulcsodat **soha** nem fogják elkérni.
|
||||
\n- A fiók-helyreállításhoz, titkos kulcsáról **mindig** készítsen biztonságos biztonsági másolatot. Javasoljuk a jelszókezelő használatát.
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
<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>
|
||||
<string name="never_translate_from">"Nooit vertalen vanuit "</string>
|
||||
<string name="error_parsing_preview_for">"Error parsing preview voor %1$s : %2$s"</string>
|
||||
<string name="preview_card_image_for">"Voorbeeld kaartafbeelding voor %1$s"</string>
|
||||
<string name="new_channel">Nieuw kanaal</string>
|
||||
@@ -156,12 +155,12 @@
|
||||
<string name="posts_received">berichten ontvangen</string>
|
||||
<string name="remove">verwijderen</string>
|
||||
<string name="sats" translatable="false">sats</string>
|
||||
<string name="auto">Auto</string>
|
||||
<string name="translated_from">vertaald van</string>
|
||||
<string name="to">naar</string>
|
||||
<string name="show_in">Laten zien in</string>
|
||||
<string name="first">eerste</string>
|
||||
<string name="always_translate_to">Altijd vertalen naar</string>
|
||||
<string name="translations_auto">Auto</string>
|
||||
<string name="translations_translated_from">vertaald van</string>
|
||||
<string name="translations_to">naar</string>
|
||||
<string name="translations_show_in_lang_first">Eerst laten zien in %1$s</string>
|
||||
<string name="translations_always_translate_to_lang">Altijd vertalen naar %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">Nooit vertalen vanuit %1$s</string>
|
||||
<string name="nip_05" translatable="false">NIP-05</string>
|
||||
<string name="lnurl" translatable="false">LNURL...</string>
|
||||
<string name="never">nooit</string>
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
<string name="thank_you_so_much">Obrigado!</string>
|
||||
<string name="amount_in_sats">Quantidade em Sats</string>
|
||||
<string name="send_sats">Enviar Sats</string>
|
||||
<string name="never_translate_from">"Nunca traduzir de "</string>
|
||||
<string name="error_parsing_preview_for">Erro ao analisar a visualização para %1$s: %2$s</string>
|
||||
<string name="preview_card_image_for">Visualize a imagem do cartão para %1$s</string>
|
||||
<string name="new_channel">Novo Canal</string>
|
||||
@@ -154,12 +153,12 @@
|
||||
<string name="public_chat">Chat público</string>
|
||||
<string name="posts_received">postagens recebidas</string>
|
||||
<string name="remove">Remover</string>
|
||||
<string name="auto">"Automaticamente "</string>
|
||||
<string name="translated_from">"traduzido de "</string>
|
||||
<string name="to">para</string>
|
||||
<string name="show_in">Mostrar em</string>
|
||||
<string name="first">primeiro</string>
|
||||
<string name="always_translate_to">"Sempre traduzir para "</string>
|
||||
<string name="translations_auto">"Automaticamente "</string>
|
||||
<string name="translations_translated_from">"traduzido de "</string>
|
||||
<string name="translations_to">para</string>
|
||||
<string name="translations_show_in_lang_first">Mostrar em %1$s primeiro</string>
|
||||
<string name="translations_always_translate_to_lang">Sempre traduzir para %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">Nunca traduzir de %1$s</string>
|
||||
<string name="never">nunca</string>
|
||||
<string name="now">agora</string>
|
||||
<string name="h">h</string>
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
<string name="thank_you_so_much">Большое спасибо!</string>
|
||||
<string name="amount_in_sats">Сумма в sat</string>
|
||||
<string name="send_sats">Отправить</string>
|
||||
<string name="never_translate_from">"Не переводить с "</string>
|
||||
<string name="error_parsing_preview_for">"Не удалось создать предпросмотр для %1$s : %2$s"</string>
|
||||
<string name="preview_card_image_for">"Предпросмотр для %1$s"</string>
|
||||
<string name="new_channel">Новый канал</string>
|
||||
@@ -152,12 +151,12 @@
|
||||
<string name="public_chat">Публичный чат</string>
|
||||
<string name="posts_received">записей получено</string>
|
||||
<string name="remove">Удалить</string>
|
||||
<string name="auto">Автоматически</string>
|
||||
<string name="translated_from">переведено с</string>
|
||||
<string name="to">на</string>
|
||||
<string name="show_in">Показать сперва на</string>
|
||||
<string name="first" />
|
||||
<string name="always_translate_to">Всегда переводить на</string>
|
||||
<string name="translations_auto">Автоматически</string>
|
||||
<string name="translations_translated_from">переведено с</string>
|
||||
<string name="translations_to">на</string>
|
||||
<string name="translations_show_in_lang_first">Показать сперва на %1$s</string>
|
||||
<string name="translations_always_translate_to_lang">Всегда переводить на %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">Не переводить с %1$s</string>
|
||||
<string name="never">никогда</string>
|
||||
<string name="now">сейчас</string>
|
||||
<string name="h">ч</string>
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
<string name="thank_you_so_much">Çok teşekkürler!</string>
|
||||
<string name="amount_in_sats">Satlarda Miktar</string>
|
||||
<string name="send_sats">Satlar Gönder</string>
|
||||
<string name="never_translate_from">Asla çevirme</string>
|
||||
<string name="new_channel">Yeni Kanal</string>
|
||||
<string name="channel_name">Kanal Adı</string>
|
||||
<string name="my_awesome_group">Benim Harika Grubum</string>
|
||||
@@ -152,12 +151,12 @@
|
||||
<string name="public_chat">Herkese Açık Chat</string>
|
||||
<string name="posts_received">alınan gönderiler</string>
|
||||
<string name="remove">Kaldır</string>
|
||||
<string name="auto">Otomatik</string>
|
||||
<string name="translated_from">şundan çevirildi</string>
|
||||
<string name="to">şuna</string>
|
||||
<string name="show_in">Şunda göster</string>
|
||||
<string name="first">ilk</string>
|
||||
<string name="always_translate_to">Her zaman şuna çevir</string>
|
||||
<string name="translations_auto">Otomatik</string>
|
||||
<string name="translations_translated_from">şundan çevirildi</string>
|
||||
<string name="translations_to">şuna</string>
|
||||
<string name="translations_show_in_lang_first">Şunda göster %1$s ilk</string>
|
||||
<string name="translations_always_translate_to_lang">Her zaman şuna çevir %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">Asla çevirme %1$s</string>
|
||||
<string name="never">asla</string>
|
||||
<string name="now">şimdi</string>
|
||||
<string name="h">h</string>
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
<string name="thank_you_so_much">Дуже дякуємо!</string>
|
||||
<string name="amount_in_sats">Сума в sat</string>
|
||||
<string name="send_sats">Надіслати</string>
|
||||
<string name="never_translate_from">"Не переводити з "</string>
|
||||
<string name="error_parsing_preview_for">"Не вдалося створити перегляд для %1$s : %2$s"</string>
|
||||
<string name="preview_card_image_for">"Перегляд для %1$s"</string>
|
||||
<string name="new_channel">Новий канал</string>
|
||||
@@ -152,12 +151,12 @@
|
||||
<string name="public_chat">Публічний чат</string>
|
||||
<string name="posts_received">дописів отримано</string>
|
||||
<string name="remove">Видалити</string>
|
||||
<string name="auto">Автоматично</string>
|
||||
<string name="translated_from">перекладено з</string>
|
||||
<string name="to">на</string>
|
||||
<string name="show_in">Показати спершу на</string>
|
||||
<string name="first" />
|
||||
<string name="always_translate_to">Завжди перекладати на</string>
|
||||
<string name="translations_auto">Автоматично</string>
|
||||
<string name="translations_translated_from">перекладено з</string>
|
||||
<string name="translations_to">на</string>
|
||||
<string name="translations_show_in_lang_first">Показати спершу на %1$s</string>
|
||||
<string name="translations_always_translate_to_lang">Завжди перекладати на %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">Не переводити з %1$s</string>
|
||||
<string name="never">ніколи</string>
|
||||
<string name="now">зараз</string>
|
||||
<string name="h">год</string>
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
<string name="thank_you_so_much">非常感謝!</string>
|
||||
<string name="amount_in_sats">聰數量</string>
|
||||
<string name="send_sats">發送聰</string>
|
||||
<string name="never_translate_from">"不再翻譯"</string>
|
||||
<string name="error_parsing_preview_for">"解析 %1$s 預覽出錯:%2$s"</string>
|
||||
<string name="preview_card_image_for">"預覽 %1$s 卡片圖片"</string>
|
||||
<string name="new_channel">新頻道</string>
|
||||
@@ -150,16 +149,15 @@
|
||||
<string name="unfollow">取關</string>
|
||||
<string name="channel_created">頻道已創建</string>
|
||||
<string name="channel_information_changed_to">頻道信息已更改為</string>
|
||||
|
||||
<string name="public_chat">公共聊天</string>
|
||||
<string name="posts_received">收到文章</string>
|
||||
<string name="remove">移除</string>
|
||||
<string name="auto">自動</string>
|
||||
<string name="translated_from">從</string>
|
||||
<string name="to">更改為</string>
|
||||
<string name="show_in">先顯示</string>
|
||||
<string name="first">在前</string>
|
||||
<string name="always_translate_to">總是翻譯為</string>
|
||||
<string name="translations_auto">自動</string>
|
||||
<string name="translations_translated_from">從</string>
|
||||
<string name="translations_to">更改為</string>
|
||||
<string name="translations_show_in_lang_first">先顯示 %1$s 在前</string>
|
||||
<string name="translations_always_translate_to_lang">總是翻譯為 %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">不再翻譯 %1$s</string>
|
||||
<string name="never">從不</string>
|
||||
<string name="now">現在</string>
|
||||
<string name="h">小時</string>
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
<string name="thank_you_so_much">非常感謝!</string>
|
||||
<string name="amount_in_sats">數量 (sats)</string>
|
||||
<string name="send_sats">發送 sats</string>
|
||||
<string name="never_translate_from">"不再翻譯"</string>
|
||||
<string name="error_parsing_preview_for">"解析 %1$s 預覽出錯:%2$s"</string>
|
||||
<string name="preview_card_image_for">"預覽 %1$s 卡片圖片"</string>
|
||||
<string name="new_channel">新頻道</string>
|
||||
@@ -150,16 +149,15 @@
|
||||
<string name="unfollow">取關</string>
|
||||
<string name="channel_created">頻道已創建</string>
|
||||
<string name="channel_information_changed_to">頻道信息已更改為</string>
|
||||
|
||||
<string name="public_chat">公共聊天</string>
|
||||
<string name="posts_received">收到文章</string>
|
||||
<string name="remove">移除</string>
|
||||
<string name="auto">自動</string>
|
||||
<string name="translated_from">從</string>
|
||||
<string name="to">更改為</string>
|
||||
<string name="show_in">先顯示</string>
|
||||
<string name="first">在前</string>
|
||||
<string name="always_translate_to">總是翻譯為</string>
|
||||
<string name="translations_auto">自動</string>
|
||||
<string name="translations_translated_from">從</string>
|
||||
<string name="translations_to">更改為</string>
|
||||
<string name="translations_show_in_lang_first">顯示 %1$s 在前</string>
|
||||
<string name="translations_always_translate_to_lang">總是翻譯為 %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">不再翻譯 %1$s</string>
|
||||
<string name="never">從不</string>
|
||||
<string name="now">現在</string>
|
||||
<string name="h">小時</string>
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
<string name="thank_you_so_much">非常感谢!</string>
|
||||
<string name="amount_in_sats">聪数量</string>
|
||||
<string name="send_sats">发送聪</string>
|
||||
<string name="never_translate_from">"不再翻译"</string>
|
||||
<string name="error_parsing_preview_for">"解析 %1$s 预览出错:%2$s"</string>
|
||||
<string name="preview_card_image_for">"预览 %1$s 卡片图片"</string>
|
||||
<string name="new_channel">新频道</string>
|
||||
@@ -153,12 +152,12 @@
|
||||
<string name="public_chat">公共聊天</string>
|
||||
<string name="posts_received">收到文章</string>
|
||||
<string name="remove">移除</string>
|
||||
<string name="auto">自动</string>
|
||||
<string name="translated_from">从</string>
|
||||
<string name="to">更改为</string>
|
||||
<string name="show_in">先显示</string>
|
||||
<string name="first">在前</string>
|
||||
<string name="always_translate_to">总是翻译为</string>
|
||||
<string name="translations_auto">自动</string>
|
||||
<string name="translations_translated_from">从</string>
|
||||
<string name="translations_to">更改为</string>
|
||||
<string name="translations_show_in_lang_first">先显示 %1$s 在前</string>
|
||||
<string name="translations_always_translate_to_lang">总是翻译为 %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">不再翻译 %1$s</string>
|
||||
<string name="never">从不</string>
|
||||
<string name="now">现在</string>
|
||||
<string name="h">小时</string>
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
<string name="thank_you_so_much">Thank you so much!</string>
|
||||
<string name="amount_in_sats">Amount in Sats</string>
|
||||
<string name="send_sats">Send Sats</string>
|
||||
<string name="never_translate_from">"Never translate from "</string>
|
||||
<string name="error_parsing_preview_for">"Error parsing preview for %1$s : %2$s"</string>
|
||||
<string name="preview_card_image_for">"Preview Card Image for %1$s"</string>
|
||||
<string name="new_channel">New Channel</string>
|
||||
@@ -157,12 +156,12 @@
|
||||
<string name="posts_received">posts received</string>
|
||||
<string name="remove">Remove</string>
|
||||
<string name="sats" translatable="false">sats</string>
|
||||
<string name="auto">Auto</string>
|
||||
<string name="translated_from">translated from</string>
|
||||
<string name="to">to</string>
|
||||
<string name="show_in">Show in</string>
|
||||
<string name="first">first</string>
|
||||
<string name="always_translate_to">Always translate to</string>
|
||||
<string name="translations_auto">Auto</string>
|
||||
<string name="translations_translated_from">translated from</string>
|
||||
<string name="translations_to">to</string>
|
||||
<string name="translations_show_in_lang_first">Show in %1$s first</string>
|
||||
<string name="translations_always_translate_to_lang">Always translate to %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">Never translate from %1$s</string>
|
||||
<string name="nip_05" translatable="false">NIP-05</string>
|
||||
<string name="lnurl" translatable="false">LNURL...</string>
|
||||
<string name="never">never</string>
|
||||
@@ -198,6 +197,23 @@
|
||||
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">Copied author’s @npub to clipboard</string>
|
||||
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">Copied note ID (@note1) to clipboard</string>
|
||||
<string name="select_text_dialog_top">Select Text</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/<user>/<gist></string>
|
||||
<string name="telegram_proof_url_template" translatable="false">https://t.me/<proof post></string>
|
||||
<string name="mastodon_proof_url_template" translatable="false">https://<server>/<user>/<proof post></string>
|
||||
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/<user>/status/<proof post></string>
|
||||
<string name="private_conversation_notification">"<Unable to decrypt private message>\n\nYou were cited in a private/encrypted conversation between %1$s and %2$s."</string>
|
||||
<string name="account_switch_add_account_dialog_title">Add New Account</string>
|
||||
<string name="drawer_accounts">Accounts</string>
|
||||
<string name="account_switch_select_account">Select Account</string>
|
||||
<string name="account_switch_add_account_btn">Add New Account</string>
|
||||
<string name="account_switch_active_account">Active account</string>
|
||||
<string name="account_switch_has_private_key">Has private key</string>
|
||||
<string name="account_switch_pubkey_only">Read only, no private key</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="quick_action_select">Select</string>
|
||||
<string name="quick_action_share_browser_link">Share Browser Link</string>
|
||||
<string name="quick_action_share">Share</string>
|
||||
@@ -209,24 +225,25 @@
|
||||
<string name="quick_action_follow">Follow</string>
|
||||
<string name="quick_action_request_deletion_alert_title">Request Deletion</string>
|
||||
<string name="quick_action_request_deletion_alert_body">Amethyst will request that your note be deleted from the relays you are currently connected to. There is no guarantee that your note will be permanently deleted from those relays, or from other relays where it may be stored.</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/<user>/<gist></string>
|
||||
<string name="telegram_proof_url_template" translatable="false">https://t.me/<proof post></string>
|
||||
<string name="mastodon_proof_url_template" translatable="false">https://<server>/<user>/<proof post></string>
|
||||
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/<user>/status/<proof post></string>
|
||||
<string name="private_conversation_notification">"<Unable to decrypt private message>\n\nYou were cited in a private/encrypted conversation between %1$s and %2$s."</string>
|
||||
<string name="quick_action_block_dialog_btn">Block</string>
|
||||
<string name="quick_action_delete_dialog_btn">Delete</string>
|
||||
<string name="quick_action_block">Block</string>
|
||||
<string name="quick_action_report">Report</string>
|
||||
<string name="quick_action_delete_button">Delete</string>
|
||||
<string name="quick_action_dont_show_again_button">Don\'t show again</string>
|
||||
<string name="account_switch_add_account_dialog_title">Add New Account</string>
|
||||
<string name="drawer_accounts">Accounts</string>
|
||||
<string name="account_switch_select_account">Select Account</string>
|
||||
<string name="account_switch_add_account_btn">Add New Account</string>
|
||||
<string name="account_switch_active_account">Active account</string>
|
||||
<string name="account_switch_has_private_key">Has private key</string>
|
||||
<string name="account_switch_pubkey_only">Read only, no private key</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="report_dialog_spam">Spam or scams</string>
|
||||
<string name="report_dialog_profanity">Profanity or hateful conduct</string>
|
||||
<string name="report_dialog_impersonation">Malicious impersonation</string>
|
||||
<string name="report_dialog_nudity">Nudity or graphic content</string>
|
||||
<string name="report_dialog_illegal">Illegal Behavior</string>
|
||||
<string name="report_dialog_blocking_a_user">Blocking a user will hide their content in your app. Your notes are still publicly viewable, including to people you block. Blocked users are listed on the Security Filters screen.</string>
|
||||
<string name="report_dialog_block_hide_user_btn"><![CDATA[Block & Hide User]]></string>
|
||||
<string name="report_dialog_report_btn">Report Abuse</string>
|
||||
<string name="report_dialog_reminder_public">All reports posted will be publicly visible.</string>
|
||||
<string name="report_dialog_additional_reason_placeholder">Optionally provide additional context about your report…</string>
|
||||
<string name="report_dialog_additional_reason_label">Additional Context</string>
|
||||
<string name="report_dialog_select_reason_label">Reason</string>
|
||||
<string name="report_dialog_select_reason_placeholder">Select a reason…</string>
|
||||
<string name="report_dialog_post_report_btn">Post Report</string>
|
||||
|
||||
</resources>
|
||||
|
||||
@@ -111,17 +111,17 @@ fun TranslateableRichTextViewer(
|
||||
val annotatedTranslationString = buildAnnotatedString {
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("langSettings", true.toString())
|
||||
append(stringResource(R.string.auto))
|
||||
append(stringResource(R.string.translations_auto))
|
||||
}
|
||||
|
||||
append("-${stringResource(R.string.translated_from)} ")
|
||||
append("-${stringResource(R.string.translations_translated_from)} ")
|
||||
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("showOriginal", true.toString())
|
||||
append(Locale(source).displayName)
|
||||
}
|
||||
|
||||
append(" ${stringResource(R.string.to)} ")
|
||||
append(" ${stringResource(R.string.translations_to)} ")
|
||||
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("showOriginal", false.toString())
|
||||
@@ -166,7 +166,7 @@ fun TranslateableRichTextViewer(
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text(stringResource(R.string.never_translate_from) + Locale(source).displayName)
|
||||
Text(stringResource(R.string.translations_never_translate_from_lang, Locale(source).displayName))
|
||||
}
|
||||
Divider()
|
||||
DropdownMenuItem(onClick = {
|
||||
@@ -185,13 +185,7 @@ fun TranslateableRichTextViewer(
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text(
|
||||
"${stringResource(R.string.show_in)} ${Locale(source).displayName} ${
|
||||
stringResource(
|
||||
R.string.first
|
||||
)
|
||||
}"
|
||||
)
|
||||
Text(stringResource(R.string.translations_show_in_lang_first, Locale(source).displayName))
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.prefer(source, target, target)
|
||||
@@ -209,13 +203,7 @@ fun TranslateableRichTextViewer(
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text(
|
||||
"${stringResource(R.string.show_in)} ${Locale(target).displayName} ${
|
||||
stringResource(
|
||||
R.string.first
|
||||
)
|
||||
}"
|
||||
)
|
||||
Text(stringResource(R.string.translations_show_in_lang_first, Locale(target).displayName))
|
||||
}
|
||||
Divider()
|
||||
|
||||
@@ -239,7 +227,7 @@ fun TranslateableRichTextViewer(
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text("${stringResource(R.string.always_translate_to)}${lang.displayName}")
|
||||
Text(stringResource(R.string.translations_always_translate_to_lang, lang.displayName))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package android.util;
|
||||
|
||||
public class Log {
|
||||
public static int d(String tag, String msg) {
|
||||
System.out.println("DEBUG: " + tag + ": " + msg);
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static int i(String tag, String msg) {
|
||||
System.out.println("INFO: " + tag + ": " + msg);
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static int w(String tag, String msg) {
|
||||
System.out.println("WARN: " + tag + ": " + msg);
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static int w(String tag, String msg, Throwable e) {
|
||||
System.out.println("WARN: " + tag + ": " + msg);
|
||||
e.printStackTrace();
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static int e(String tag, String msg) {
|
||||
System.out.println("ERROR: " + tag + ": " + msg);
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static int e(String tag, String msg, Throwable e) {
|
||||
System.out.println("ERROR: " + tag + ": " + msg);
|
||||
e.printStackTrace();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||