Compare commits

...

52 Commits

Author SHA1 Message Date
Vitor Pamplona
0da542fede 0.22.0 2023-03-01 19:18:30 -05:00
Vitor Pamplona
d883cc32f5 Early support for Markdown and Long Form Text 2023-03-01 19:18:07 -05:00
Vitor Pamplona
64debb79fc Merge pull request #171 from Gnivom/login_safe_nsec_entry
Use KeyboardType.Password for login text field
2023-03-01 16:36:55 -05:00
Vitor Pamplona
4ee6759957 Merge pull request #170 from rashedswen/patch-2
Update strings.xml
2023-03-01 16:36:16 -05:00
Vitor Pamplona
723db61e8d v0.21.2 2023-03-01 16:30:35 -05:00
Vitor Pamplona
cb39281268 Adjustments to strings 2023-03-01 16:27:20 -05:00
Vitor Pamplona
baa1b3f69f Using accountViewModel as a better way to request refreshes. 2023-03-01 16:22:22 -05:00
Gnivom
c7bf4240dd Use KeyboardType.Password for login text field 2023-03-01 22:21:19 +01:00
Vitor Pamplona
fb7edd1797 Hides hidden users from main feeds. 2023-03-01 16:15:49 -05:00
Rashed
4448e38100 Update strings.xml 2023-03-02 00:13:38 +03:00
Vitor Pamplona
716ebd01df Decreasing the speed of the updates of the feed. 2023-03-01 16:07:10 -05:00
Vitor Pamplona
58521b27de Fix for a String.format crashing the original library by using the Locale's default number format. 2023-03-01 15:48:18 -05:00
Vitor Pamplona
f4db64fe7f 0.20.1 2023-03-01 14:00:39 -05:00
Vitor Pamplona
d704352f1f Notifications now require the Notified to be the author of the post the notification is mentioning. 2023-03-01 14:00:03 -05:00
Vitor Pamplona
511cc86025 v0.21.0 2023-03-01 13:32:45 -05:00
Vitor Pamplona
58e8e0591b Keeping on Java11 to align with Android's capabilities. 2023-03-01 13:32:33 -05:00
Vitor Pamplona
57fc149051 Merge remote-tracking branch 'origin/HEAD' 2023-03-01 13:30:14 -05:00
Vitor Pamplona
55cc780c08 Adding the ability to click and hold to copy the image url on profile. 2023-03-01 13:29:58 -05:00
Vitor Pamplona
01a7ef3785 Merge pull request #168 from greenart7c3/main
Translated to brazilian portuguese
2023-03-01 13:28:19 -05:00
greenart7c3
b77e1cf960 Translated to brazilian portuguese 2023-03-01 14:47:41 -03:00
Vitor Pamplona
c6eb5a7282 Fixes image resizes to 200x200 pixels to help with caching capabilities. 2023-03-01 11:58:37 -05:00
Vitor Pamplona
bf48428fe3 Adds an Origin tag to be able to connect with Snort's relay. 2023-03-01 11:57:20 -05:00
Vitor Pamplona
f4b366c4bd Fixing merge issue on reported Types 2023-03-01 10:07:59 -05:00
Vitor Pamplona
51ce424352 Disables the forced Locale to English due to i18n for Arabic 2023-03-01 10:04:08 -05:00
Vitor Pamplona
01b8b2a5fa Merge remote-tracking branch 'origin/HEAD'
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt
2023-03-01 10:02:41 -05:00
Vitor Pamplona
459e503c8a Avoids flickering the screen when the update from Loading to Loaded happens on cached objects. 2023-03-01 09:34:14 -05:00
Vitor Pamplona
708f6b2e75 Merge pull request #166 from rashedswen/main
extract strings and add Arabic Language
2023-03-01 09:03:41 -05:00
Rashed
b7b7f4df39 Merge branch 'main' into main 2023-03-01 09:16:59 +02:00
Rashed
855b2ff5b1 resolve conflicts 2023-03-01 10:10:27 +03:00
Rashed
67e6349877 resolve conflicts but new issue found when changing the language 2023-03-01 07:54:27 +03:00
Rashed
5eb3b2062c Update strings.xml 2023-03-01 07:50:01 +03:00
Rashed
c649d0820c Update strings.xml
update just wrong translate
2023-03-01 07:50:01 +03:00
Rashed
07fd8fc5ac extract most strings to strings.xml and add arabic language to strings.xml file 2023-03-01 07:50:01 +03:00
Rashed
e6afa7fb9a extract most strings to strings.xml and add arabic language to strings.xml file 2023-03-01 07:50:01 +03:00
Vitor Pamplona
e0596dc3b9 New Version for PlayStore 2023-02-28 21:24:40 -05:00
Vitor Pamplona
381db713ab 0.20.4 2023-02-28 20:13:36 -05:00
Vitor Pamplona
b6f5cc9ae5 Activating the Atomic Boolean instead of Synchronized sections 2023-02-28 20:10:52 -05:00
Vitor Pamplona
df43e730ca Migrating the NostrDataSource to Atomic Boolean 2023-02-28 19:52:16 -05:00
Vitor Pamplona
95423bdd94 No need for account in search 2023-02-28 19:50:45 -05:00
Vitor Pamplona
a18479e721 Reducing the need to start/stop home and chatroom lists to see if performance increases 2023-02-28 18:24:28 -05:00
Vitor Pamplona
f68f32f6e5 Decrypting notes since Notifications can also show Private Message Likes 2023-02-28 16:51:35 -05:00
Vitor Pamplona
ddc1e9fd69 Reverts to show all replies in Notifications. 2023-02-28 15:35:41 -05:00
Vitor Pamplona
3f99b602a7 Fixing spacing of the multicard notification 2023-02-28 15:27:28 -05:00
Vitor Pamplona
b09577f816 Updating reports to the latest spec 2023-02-28 15:24:23 -05:00
Vitor Pamplona
3780ed001a Refining template texts 2023-02-28 11:37:39 -05:00
Vitor Pamplona
f8e49df126 Update issue templates 2023-02-28 11:34:12 -05:00
Vitor Pamplona
55e31d445e Simplifying relay set feature 2023-02-28 11:02:05 -05:00
Vitor Pamplona
5300d7bbe4 Review of Feature List 2023-02-28 11:01:24 -05:00
Vitor Pamplona
77fbb6f4f6 v0.20.3 2023-02-27 21:13:46 -05:00
Vitor Pamplona
b570d80ac4 adding a delay to the search screen update on start up 2023-02-27 21:04:51 -05:00
Vitor Pamplona
a7e17c810d Merging all notification reactions in one card. 2023-02-27 21:04:29 -05:00
Vitor Pamplona
256de32b7c Shorter Blank and Hidden notes. 2023-02-27 19:17:09 -05:00
85 changed files with 1903 additions and 504 deletions

32
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,32 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Video and Screenshots**
If applicable, add a video and/or screenshots to help explain your problem.
**Device (please complete the following information):**
- Phone Brand/Model [e.g. Pixel 7 Pro]:
- Android Version [e.g. 33]:
- App Version [e.g. v0.20.3]:
**Bounty (in Bitcoin sats) offered for a solution**
Incentivize developers to work on your issue. Describe clear milestones to claim payment.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE]"
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Bounty (in Bitcoin sats) offered for the implementation**
Incentivize developers to work on your feature. Describe clear milestones to claim payment.
**Additional context**
Add any other context, video, or screenshots about the feature request here.

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
Amethyst

View File

@@ -5,6 +5,9 @@
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />

6
.idea/kotlinc.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.10" />
</component>
</project>

2
.idea/misc.xml generated
View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

View File

@@ -6,31 +6,48 @@ Amethyst brings the best social network to your Android phone. Just insert your
# Current Features
- [x] Account Management
- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15)
- [x] Home Feed
- [x] Notifications Feed
- [x] Global Feed
- [x] Reactions (like, boost, reply)
- [x] Image/Video Preview (gifs, svgs, etc)
- [x] Url Preview
- [x] Reactions (likes NIP-25, boost, reply)
- [x] Image/Video/Url/LnInvoice Previews
- [x] View Threads
- [x] Private Messages (NIP-04)
- [x] User Profiles (follow/unfollow)
- [x] User Profiles (edit/follow/unfollow - NIP-02)
- [x] Public Chats (NIP-28)
- [x] Bech Encoding support (NIP-19)
- [x] Notification Dots
- [x] Reporting and Hide User capability
- [x] Reporting and Hide User capability (NIP-56)
- [x] Automatic Translations
- [x] Profile Edit
- [x] Relay Edit (separate relay sets for home, dms, public chats, global)
- [x] Dropdown to Link Users when writing
- [x] Relay Sets (home, dms, public chats, global)
- [x] User/Note Tagging (NIP-08, NIP-10)
- [x] Lightning Tips
- [x] Zaps
- [x] Zaps (NIP-57)
- [x] URI Support (NIP-21)
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
- [x] Identity Verification (NIP-05)
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Sensitive Content (NIP-36)
- [ ] Relay Pages (NIP-11)
- [ ] Relay Authentication (NIP-42)
- [ ] Generic Tags (NIP-12)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Events with a Subject (NIP-14)
- [ ] Long-form Content (NIP-23)
- [ ] Online Relay Search (NIP-50)
- [ ] Workspaces
- [ ] Expiration Support (NIP-40)
- [ ] Internationalization
- [ ] Infinity Scroll
- [ ] Identity Verification (NIP-05)
- [ ] Account Creation / Backup Guidance
- [ ] Message Sent feedback
- [ ] Infinity Scroll
- [ ] Badges (NIP-58)
- [ ] Relay List Metadata (NIP-65)
- [ ] Signing Requests (NIP-46)
- [ ] Delegated Event Signing (NIP-26)
- [ ] Account Creation / Backup Guidance (NIP-06)
- [ ] Message Sent feedback (NIP-20)
# Development Overview

View File

@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 76
versionName "0.20.2"
versionCode 83
versionName "0.22.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -130,6 +130,11 @@ dependencies {
implementation 'androidx.camera:camera-lifecycle:1.2.1'
implementation 'androidx.camera:camera-view:1.2.1'
// Markdown
implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// For QR Scanning
implementation 'com.google.mlkit:vision-common:17.3.0'

View File

@@ -44,12 +44,10 @@ class NotificationLiveData(val cache: NotificationCache): LiveData<NotificationS
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {

View File

@@ -1,22 +1,29 @@
package com.vitorpamplona.amethyst
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.util.LruCache
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import java.util.UUID
import name.neuhalfen.projects.android.robohash.RoboHash
import name.neuhalfen.projects.android.robohash.buckets.VariableSizeHashing
import name.neuhalfen.projects.android.robohash.handle.Handle
import name.neuhalfen.projects.android.robohash.handle.HandleFactory
import name.neuhalfen.projects.android.robohash.paths.Configuration
import name.neuhalfen.projects.android.robohash.repository.ImageRepository
object RoboHashCache {
lateinit var robots: RoboHash
lateinit var robots: MyRoboHash
lateinit var defaultAvatar: ImageBitmap
@Synchronized
fun get(context: Context, hash: String): ImageBitmap {
if (!this::robots.isInitialized) {
robots = RoboHash(context)
//robots.useCache(LruCache(100));
robots = MyRoboHash(context)
defaultAvatar = robots.imageForHandle(robots.calculateHandleFromUUID(UUID.nameUUIDFromBytes("aaaa".toByteArray()))).asImageBitmap()
}
@@ -24,4 +31,139 @@ object RoboHashCache {
return defaultAvatar
}
}
}
/**
* Recreates RoboHash to use a custom configuration
*/
class MyRoboHash(context: Context) {
private val configuration: Configuration = ModifiedSet1Configuration()
private val repository: ImageRepository
private val hashing = VariableSizeHashing(configuration.bucketSizes)
// Optional
private var memoryCache: LruCache<String, Bitmap>? = null
init {
repository = ImageRepository(context.assets)
}
fun useCache(memoryCache: LruCache<String, Bitmap>?) {
this.memoryCache = memoryCache
}
fun calculateHandleFromUUID(uuid: UUID?): Handle {
val data = hashing.createBuckets(uuid)
return handleFactory.calculateHandle(data)
}
fun imageForHandle(handle: Handle): Bitmap {
if (null != memoryCache) {
val cached = memoryCache!![handle.toString()]
if (null != cached) return cached
}
val bucketValues = handle.bucketValues()
val paths = configuration.convertToFacetParts(bucketValues)
val sampleSize = 1
val buffer = repository.createBuffer(configuration.width(), configuration.height())
val target = buffer.copy(Bitmap.Config.ARGB_8888, true)
val merged = Canvas(target)
val paint = Paint(0)
// The first image is not added as copy form the buffer
for (i in paths.indices) {
merged.drawBitmap(repository.getInto(buffer, paths[i], sampleSize), 0f, 0f, paint)
}
repository.returnBuffer(buffer)
if (null != memoryCache) {
memoryCache!!.put(handle.toString(), target)
}
return target
}
companion object {
private val handleFactory = HandleFactory()
}
}
/**
* Custom configuration to avoid the use of String.format in the GeneratePath
* This uses the default location and ends up encoding number in the local language
*/
class ModifiedSet1Configuration : Configuration {
override fun convertToFacetParts(bucketValues: ByteArray): Array<String> {
require(bucketValues.size == BUCKET_COUNT)
val color = INT_TO_COLOR[bucketValues[BUCKET_COLOR].toInt()]
val paths = mutableListOf<String>()
// e.g.
// blue face #2
// blue nose #7
// blue
val firstFacetBucket = BUCKET_COLOR + 1
for (facet in 0 until FACET_COUNT) {
val bucketValue = bucketValues[firstFacetBucket + facet].toInt()
paths.add(generatePath(FACET_PATH_TEMPLATES[facet], color, bucketValue))
}
return paths.toTypedArray()
}
private fun generatePath(facetPathTemplate: String, color: String, bucketValue: Int): String {
// TODO: Make more efficient
return facetPathTemplate.replace("#ROOT#", ROOT).replace("#COLOR#".toRegex(), color)
.replace("#ITEM#".toRegex(), (bucketValue + 1).toString().padStart(2, '0'))
}
override fun getBucketSizes(): ByteArray {
return BUCKET_SIZES
}
override fun width(): Int {
return 300
}
override fun height(): Int {
return 300
}
companion object {
private const val ROOT = "sets/set1"
private const val BUCKET_COLOR = 0
private const val COLOR_COUNT = 10
private const val BODY_COUNT = 10
private const val FACE_COUNT = 10
private const val MOUTH_COUNT = 10
private const val EYES_COUNT = 10
private const val ACCESSORY_COUNT = 10
private const val BUCKET_COUNT = 6
private const val FACET_COUNT = 5
private val BUCKET_SIZES = byteArrayOf(
COLOR_COUNT.toByte(),
BODY_COUNT.toByte(),
FACE_COUNT.toByte(),
MOUTH_COUNT.toByte(),
EYES_COUNT.toByte(),
ACCESSORY_COUNT.toByte()
)
private val INT_TO_COLOR = arrayOf(
"blue",
"brown",
"green",
"grey",
"orange",
"pink",
"purple",
"red",
"white",
"yellow"
)
private val FACET_PATH_TEMPLATES = arrayOf(
"#ROOT#/#COLOR#/01Body/#COLOR#_body-#ITEM#.png",
"#ROOT#/#COLOR#/02Face/#COLOR#_face-#ITEM#.png",
"#ROOT#/#COLOR#/Mouth/#COLOR#_mouth-#ITEM#.png",
"#ROOT#/#COLOR#/Eyes/#COLOR#_eyes-#ITEM#.png",
"#ROOT#/#COLOR#/Accessory/#COLOR#_accessory-#ITEM#.png"
)
}
}

View File

@@ -33,7 +33,6 @@ object ServiceManager {
NostrAccountDataSource.account = myAccount
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrGlobalDataSource.account = myAccount
// Notification Elements
NostrAccountDataSource.start()

View File

@@ -605,11 +605,9 @@ class Account(
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {

View File

@@ -67,12 +67,10 @@ class AntiSpamLiveData(val cache: AntiSpamFilter): LiveData<AntiSpamState>(AntiS
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {

View File

@@ -23,7 +23,6 @@ class Channel(val idHex: String) {
return info.name ?: idDisplayNote()
}
@Synchronized
fun addNote(note: Note) {
notes[note.idHex] = note
}

View File

@@ -12,6 +12,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
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.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
@@ -184,7 +185,52 @@ object LocalCache {
refreshObservers()
}
private fun replyToWithoutCitations(event: TextNoteEvent): List<String> {
fun consume(event: LongTextNoteEvent, relay: Relay?) {
if (antiSpam.isSpam(event)) {
relay?.let {
it.spamCounter++
}
return
}
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val mentions = event.mentions.mapNotNull { checkGetOrCreateUser(it) }
val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, replyTo)
//Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content?.take(100)} ${formattedDateTime(event.createdAt)}")
// Prepares user's profile view.
author.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)
}
refreshObservers()
}
private fun findCitations(event: Event): Set<String> {
var citations = mutableSetOf<String>()
// Removes citations from replies:
val matcher = tagSearch.matcher(event.content)
@@ -198,6 +244,17 @@ object LocalCache {
}
}
return citations
}
private fun replyToWithoutCitations(event: TextNoteEvent): List<String> {
val citations = findCitations(event)
return event.replyTos.filter { it !in citations }
}
private fun replyToWithoutCitations(event: LongTextNoteEvent): List<String> {
val citations = findCitations(event)
return event.replyTos.filter { it !in citations }
}
@@ -408,8 +465,8 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
val mentions = event.reportedAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.reportedPost.mapNotNull { checkGetOrCreateNote(it) }
val mentions = event.reportedAuthor.mapNotNull { checkGetOrCreateUser(it.key) }
val repliesTo = event.reportedPost.mapNotNull { checkGetOrCreateNote(it.key) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -735,16 +792,14 @@ class LocalCacheLiveData(val cache: LocalCache): LiveData<LocalCacheState>(Local
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
delay(100)
delay(500)
refresh()
} finally {
withContext(NonCancellable) {

View File

@@ -18,8 +18,10 @@ import java.util.regex.Pattern
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.events.Event
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
@@ -318,17 +320,20 @@ class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting.set(false)
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}

View File

@@ -289,7 +289,7 @@ class User(val pubkeyHex: String) {
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
return reports[loggedIn]?.firstOrNull() {
it.event is ReportEvent && (it.event as ReportEvent).reportType.contains(type)
it.event is ReportEvent && (it.event as ReportEvent).reportedAuthor.any { it.reportType == type }
} != null
}
@@ -370,7 +370,7 @@ class UserMetadata {
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
.filter { it.startsWith(prefix, true) }.isNotEmpty()
.any { it.startsWith(prefix, true) }
}
}
@@ -379,11 +379,9 @@ class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {

View File

@@ -8,6 +8,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
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.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
@@ -17,11 +18,14 @@ import com.vitorpamplona.amethyst.service.relays.Subscription
import com.vitorpamplona.amethyst.service.relays.hasValidSignature
import java.util.Date
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.events.ContactListEvent
import nostr.postr.events.DeletionEvent
import nostr.postr.events.Event
@@ -42,6 +46,7 @@ abstract class NostrDataSource(val debugName: String) {
}
}
private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
if (subscriptionId in subscriptions.keys) {
@@ -86,12 +91,13 @@ abstract class NostrDataSource(val debugName: String) {
ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay)
ChannelHideMessageEvent.kind -> LocalCache.consume(ChannelHideMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMuteUserEvent.kind -> LocalCache.consume(ChannelMuteUserEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
LongTextNoteEvent.kind -> LocalCache.consume(LongTextNoteEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -123,10 +129,12 @@ abstract class NostrDataSource(val debugName: String) {
}
open fun start() {
println("DataSource: ${this.javaClass.simpleName} Start")
resetFilters()
}
open fun stop() {
println("DataSource: ${this.javaClass.simpleName} Stop")
subscriptions.values.forEach { channel ->
Client.close(channel.id)
channel.typedFilters = null
@@ -144,17 +152,24 @@ abstract class NostrDataSource(val debugName: String) {
subscriptions = subscriptions.minus(subscription.id)
}
var handlerWaiting = false
@Synchronized
fun invalidateFilters() {
if (handlerWaiting) return
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
fun invalidateFilters() {
if (handlerWaiting.getAndSet(true)) return
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
delay(200)
resetFiltersSuspend()
handlerWaiting = false
try {
delay(200)
resetFiltersSuspend()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}

View File

@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -8,7 +7,6 @@ import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrGlobalDataSource: NostrDataSource("GlobalFeed") {
lateinit var account: Account
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import kotlinx.coroutines.Dispatchers
@@ -47,7 +48,7 @@ object NostrHomeDataSource: NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
authors = followSet,
limit = 400
)

View File

@@ -0,0 +1,56 @@
package com.vitorpamplona.amethyst.service.model
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
class LongTextNoteEvent(
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val replyTos: List<String>
@Transient val mentions: List<String>
@Transient val title: String?
@Transient val image: String?
@Transient val summary: String?
@Transient val publishedAt: Long?
@Transient val topics: List<String>
init {
replyTos = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
mentions = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
topics = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
title = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
image = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
summary = tags.filter { it.firstOrNull() == "summary" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
publishedAt = try {
tags.filter { it.firstOrNull() == "published_at" }.mapNotNull { it.getOrNull(1) }.firstOrNull()?.toLong()
} catch (_: Exception) {
null
}
}
companion object {
const val kind = 30023
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LongTextNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = mutableListOf<List<String>>()
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return LongTextNoteEvent(id, pubKey, createdAt, tags, msg, sig)
}
}
}

View File

@@ -5,6 +5,8 @@ import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType)
// NIP 56 event.
class ReportEvent (
id: ByteArray,
@@ -15,14 +17,37 @@ class ReportEvent (
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val reportType: List<ReportType>
@Transient val reportedPost: List<String>
@Transient val reportedAuthor: List<String>
@Transient val reportedPost: List<ReportedKey>
@Transient val reportedAuthor: List<ReportedKey>
init {
reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }
reportedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
reportedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
// Works with old and new structures for report.
var reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull()
if (reportType == null) {
reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull()
}
if (reportType == null) {
reportType = ReportType.SPAM
}
reportedPost = tags
.filter { it.firstOrNull() == "e" && it.getOrNull(1) != null }
.map {
ReportedKey(
it[1],
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: reportType
)
}
reportedAuthor = tags
.filter { it.firstOrNull() == "p" && it.getOrNull(1) != null }
.map {
ReportedKey(
it[1],
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: reportType
)
}
}
companion object {
@@ -31,12 +56,11 @@ class ReportEvent (
fun create(reportedPost: Event, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
val reportTypeTag = listOf("report", type.name.toLowerCase())
val reportPostTag = listOf("e", reportedPost.id.toHex())
val reportAuthorTag = listOf("p", reportedPost.pubKey.toHex())
val reportPostTag = listOf("e", reportedPost.id.toHex(), type.name.toLowerCase())
val reportAuthorTag = listOf("p", reportedPost.pubKey.toHex(), type.name.toLowerCase())
val pubKey = Utils.pubkeyCreate(privateKey)
val tags:List<List<String>> = listOf(reportTypeTag, reportPostTag, reportAuthorTag)
val tags:List<List<String>> = listOf(reportPostTag, reportAuthorTag)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
@@ -45,11 +69,10 @@ class ReportEvent (
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
val reportTypeTag = listOf("report", type.name.toLowerCase())
val reportAuthorTag = listOf("p", reportedUser)
val reportAuthorTag = listOf("p", reportedUser, type.name.toLowerCase())
val pubKey = Utils.pubkeyCreate(privateKey)
val tags:List<List<String>> = listOf(reportTypeTag, reportAuthorTag)
val tags:List<List<String>> = listOf(reportAuthorTag)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
@@ -57,9 +80,11 @@ class ReportEvent (
}
enum class ReportType() {
EXPLICIT,
EXPLICIT, // Not used anymore.
ILLEGAL,
SPAM,
IMPERSONATION
IMPERSONATION,
NUDITY,
PROFANITY,
}
}

View File

@@ -54,7 +54,7 @@ class Relay(
if (socket != null) return
try {
val request = Request.Builder().url(url.trim()).build()
val request = Request.Builder().header("Origin", "amethyst.social").url(url.trim()).build()
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
@@ -88,7 +88,7 @@ class Relay(
"NOTICE" -> listeners.forEach {
//Log.w("Relay", "Relay onNotice $url, $channel")
// "channel" being the second string in the string array ...
it.onError(this@Relay, channel, Error("Relay sent notice: $channel"))
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
}
"OK" -> listeners.forEach {
//Log.w("Relay", "Relay onOK $url, $channel")

View File

@@ -94,7 +94,6 @@ object RelayPool: Relay.Listener {
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
}
@Synchronized
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
}

View File

@@ -21,6 +21,7 @@ import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import java.util.Locale
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {

View File

@@ -10,6 +10,7 @@ import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import java.io.File
import com.vitorpamplona.amethyst.R
import okhttp3.*
import okio.BufferedSource
import okio.IOException
@@ -49,7 +50,7 @@ object ImageSaver {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentType = response.header("Content-Type")
checkNotNull(contentType) {
"Can't find out the content type"
context.getString(R.string.can_t_find_out_the_content_type)
}
saveContentQ(
@@ -57,6 +58,7 @@ object ImageSaver {
contentType = contentType,
contentSource = response.body.source(),
contentResolver = context.contentResolver,
context = context
)
} else {
saveContentDefault(
@@ -80,6 +82,7 @@ object ImageSaver {
contentType: String,
contentSource: BufferedSource,
contentResolver: ContentResolver,
context: Context
) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
@@ -93,13 +96,13 @@ object ImageSaver {
val uri =
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
checkNotNull(uri) {
"Can't insert the new content"
context.getString(R.string.can_t_insert_the_new_content)
}
try {
val outputStream = contentResolver.openOutputStream(uri)
checkNotNull(outputStream) {
"Can't open the content output stream"
context.getString(R.string.can_t_open_the_content_output_stream)
}
outputStream.use {

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.io.IOException
@@ -8,6 +9,7 @@ import java.util.UUID
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType
import com.vitorpamplona.amethyst.R
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
@@ -23,6 +25,7 @@ object ImageUploader {
contentResolver: ContentResolver,
onSuccess: (String) -> Unit,
onError: (Throwable) -> Unit,
context : Context
) {
val contentType = contentResolver.getType(uri)
@@ -40,7 +43,7 @@ object ImageUploader {
override fun writeTo(sink: BufferedSink) {
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) {
"Can't open the image input stream"
context.getString(R.string.can_t_open_the_image_input_stream)
}
imageInputStream.source().use(sink::writeAll)
@@ -63,7 +66,7 @@ object ImageUploader {
val tree = jacksonObjectMapper().readTree(body.string())
val url = tree?.get("data")?.get("link")?.asText()
checkNotNull(url) {
"There must be an uploaded image URL in the response"
context.getString(R.string.there_must_be_an_uploaded_image_url_in_the_response)
}
onSuccess(url)

View File

@@ -17,12 +17,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
@@ -67,13 +70,13 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = "Channel Name") },
label = { Text(text = stringResource(R.string.channel_name)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.channelName.value,
onValueChange = { postViewModel.channelName.value = it },
placeholder = {
Text(
text = "My Awesome Group",
text = stringResource(R.string.my_awesome_group),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
@@ -86,7 +89,7 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = "Picture Url") },
label = { Text(text = stringResource(R.string.picture_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.channelPicture.value,
onValueChange = { postViewModel.channelPicture.value = it },
@@ -101,13 +104,13 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = "Description") },
label = { Text(text = stringResource(R.string.description)) },
modifier = Modifier.fillMaxWidth().height(100.dp),
value = postViewModel.channelDescription.value,
onValueChange = { postViewModel.channelDescription.value = it },
placeholder = {
Text(
text = "About us.. ",
text = stringResource(R.string.about_us),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},

View File

@@ -25,6 +25,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
@@ -136,7 +137,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
},
placeholder = {
Text(
text = "What's on your mind?",
text = stringResource(R.string.what_s_on_your_mind),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
@@ -221,7 +222,7 @@ fun CloseButton(onCancel: () -> Unit) {
backgroundColor = Color.Gray
)
) {
Text(text = "Cancel", color = Color.White)
Text(text = stringResource(R.string.cancel), color = Color.White)
}
}
@@ -240,7 +241,7 @@ fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier =
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Post", color = Color.White)
Text(text = stringResource(R.string.post), color = Color.White)
}
}
@@ -259,7 +260,7 @@ fun SaveButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier =
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Save", color = Color.White)
Text(text = stringResource(R.string.save), color = Color.White)
}
}
@@ -278,7 +279,7 @@ fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Create", color = Color.White)
Text(text = stringResource(R.string.create), color = Color.White)
}
}

View File

@@ -14,6 +14,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.parseDirtyWordForKey
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -133,9 +134,10 @@ class NewPostViewModel: ViewModel() {
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image")
imageUploadingError.emit(context.getString(R.string.failed_to_upload_the_image))
}
}
},
context = context
)
}

View File

@@ -41,6 +41,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -141,7 +142,7 @@ fun ServerConfigHeader() {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Relay Address",
text = stringResource(R.string.relay_address),
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -154,7 +155,7 @@ fun ServerConfigHeader() {
Spacer(modifier = Modifier.size(25.dp))
Text(
text = "Posts",
text = stringResource(R.string.posts),
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
@@ -164,7 +165,7 @@ fun ServerConfigHeader() {
Spacer(modifier = Modifier.size(10.dp))
Text(
text = "Posts",
text = stringResource(id = R.string.posts),
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
@@ -174,7 +175,7 @@ fun ServerConfigHeader() {
Spacer(modifier = Modifier.size(10.dp))
Text(
text = "Errors",
text = stringResource(R.string.errors),
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
@@ -227,7 +228,9 @@ fun ServerConfig(
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier.padding(end = 5.dp).size(15.dp),
modifier = Modifier
.padding(end = 5.dp)
.size(15.dp),
tint = Color.Red
)
}
@@ -252,8 +255,10 @@ fun ServerConfig(
) {
Icon(
painterResource(R.drawable.ic_home),
"Home Feed",
modifier = Modifier.padding(end = 5.dp).size(15.dp),
stringResource(R.string.home_feed),
modifier = Modifier
.padding(end = 5.dp)
.size(15.dp),
tint = if (item.feedTypes.contains(FeedType.FOLLOWS)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
@@ -265,8 +270,10 @@ fun ServerConfig(
) {
Icon(
painterResource(R.drawable.ic_dm),
"Private Message Feed",
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
stringResource(R.string.private_message_feed),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
tint = if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
@@ -278,8 +285,10 @@ fun ServerConfig(
) {
Icon(
imageVector = Icons.Default.Groups,
"Public Chat Feed",
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
stringResource(R.string.public_chat_feed),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
tint = if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
@@ -291,8 +300,10 @@ fun ServerConfig(
) {
Icon(
imageVector = Icons.Default.Public,
"Global Feed",
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
stringResource(R.string.global_feed),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
tint = if (item.feedTypes.contains(FeedType.GLOBAL)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
@@ -310,7 +321,9 @@ fun ServerConfig(
Icon(
imageVector = Icons.Default.Download,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
tint = if (item.read) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
@@ -332,7 +345,9 @@ fun ServerConfig(
Icon(
imageVector = Icons.Default.Upload,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
tint = if (item.write) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
@@ -350,7 +365,9 @@ fun ServerConfig(
Icon(
imageVector = Icons.Default.SyncProblem,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
tint = if (item.errorCount > 0) Color.Yellow else Color.Green
)
@@ -396,7 +413,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
label = { Text(text = "Add a Relay") },
label = { Text(text = stringResource(R.string.add_a_relay)) },
modifier = Modifier.weight(1f),
value = url,
onValueChange = { url = it },
@@ -449,7 +466,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
backgroundColor = if (url.isNotBlank()) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
) {
Text(text = "Add", color = Color.White)
Text(text = stringResource(id = R.string.add), color = Color.White)
}
}

View File

@@ -17,11 +17,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@OptIn(ExperimentalComposeUiApi::class)
@@ -69,13 +71,13 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Row(modifier = Modifier.fillMaxWidth(1f), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
label = { Text(text = "Display Name") },
label = { Text(text = stringResource(R.string.display_name)) },
modifier = Modifier.weight(1f),
value = postViewModel.displayName.value,
onValueChange = { postViewModel.displayName.value = it },
placeholder = {
Text(
text = "My display name",
text = stringResource(R.string.my_display_name),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
@@ -88,13 +90,13 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Text("@", Modifier.padding(5.dp))
OutlinedTextField(
label = { Text(text = "Username") },
label = { Text(text = stringResource(R.string.username)) },
modifier = Modifier.weight(1f),
value = postViewModel.userName.value,
onValueChange = { postViewModel.userName.value = it },
placeholder = {
Text(
text = "My username",
text = stringResource(R.string.my_username),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
@@ -105,7 +107,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "About me") },
label = { Text(text = stringResource(R.string.about_me)) },
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
@@ -113,7 +115,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
onValueChange = { postViewModel.about.value = it },
placeholder = {
Text(
text = "About me",
text = stringResource(id = R.string.about_me),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
@@ -126,7 +128,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Avatar URL") },
label = { Text(text = stringResource(R.string.avatar_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.picture.value,
onValueChange = { postViewModel.picture.value = it },
@@ -142,7 +144,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Banner URL") },
label = { Text(text = stringResource(R.string.banner_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.banner.value,
onValueChange = { postViewModel.banner.value = it },
@@ -158,7 +160,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Website URL") },
label = { Text(text = stringResource(R.string.website_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.website.value,
onValueChange = { postViewModel.website.value = it },
@@ -174,7 +176,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "NIP-05") },
label = { Text(text = stringResource(R.string.nip_05)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.nip05.value,
onValueChange = { postViewModel.nip05.value = it },
@@ -189,7 +191,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "LN Address") },
label = { Text(text = stringResource(R.string.ln_address)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnAddress.value,
onValueChange = { postViewModel.lnAddress.value = it },
@@ -205,13 +207,13 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "LN URL (outdated)") },
label = { Text(text = stringResource(R.string.ln_url_outdated)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnURL.value,
onValueChange = { postViewModel.lnURL.value = it },
placeholder = {
Text(
text = "LNURL...",
text = stringResource(R.string.lnurl),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},

View File

@@ -11,10 +11,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import kotlinx.coroutines.launch
/**
@@ -29,6 +31,7 @@ fun SaveToGallery(url: String) {
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
fun saveImage() {
ImageSaver.saveImage(
context = localContext,
@@ -37,7 +40,7 @@ fun SaveToGallery(url: String) {
scope.launch {
Toast.makeText(
localContext,
"Image saved to the gallery",
localContext.getString(R.string.image_saved_to_the_gallery),
Toast.LENGTH_SHORT
)
.show()
@@ -47,7 +50,7 @@ fun SaveToGallery(url: String) {
scope.launch {
Toast.makeText(
localContext,
"Failed to save the image",
localContext.getString(R.string.failed_to_save_the_image),
Toast.LENGTH_SHORT
)
.show()
@@ -78,6 +81,6 @@ fun SaveToGallery(url: String) {
backgroundColor = Color.Gray
)
) {
Text(text = "Save", color = Color.White)
Text(text = stringResource(id = R.string.save), color = Color.White)
}
}

View File

@@ -12,10 +12,12 @@ import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
@OptIn(ExperimentalPermissionsApi::class)
@Composable
@@ -54,9 +56,9 @@ fun UploadFromGallery(
}
) {
if (!isUploading) {
Text("Upload Image")
Text(stringResource(R.string.upload_image))
} else {
Text("Uploading")
Text(stringResource(R.string.uploading))
}
}
}
@@ -68,9 +70,9 @@ fun UploadFromGallery(
enabled = !isUploading,
) {
if (!isUploading) {
Text("Upload Image")
Text(stringResource(R.string.upload_image))
} else {
Text("Uploading")
Text(stringResource(R.string.uploading))
}
}
}

View File

@@ -16,7 +16,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
@@ -38,7 +40,7 @@ fun NewChannelButton(account: Account) {
) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = "New Channel",
contentDescription = stringResource(R.string.new_channel),
modifier = Modifier.size(26.dp),
tint = Color.White
)

View File

@@ -16,7 +16,17 @@ import coil.compose.AsyncImagePainter
import coil.compose.LocalImageLoader
import java.util.Base64
data class ResizeImage(val url: String?, val size: Dp)
data class ResizeImage(val url: String?, val size: Dp) {
fun proxyUrl(): String? {
if (url == null) return null
// Fixes Image size to reduce pings to servers for each size used in the app
val imgPx = 200 // with(LocalDensity.current) { model.size.toPx().toInt() }
val base64 = Base64.getUrlEncoder().encodeToString(url.toByteArray())
return "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64"
}
}
@Composable
@@ -40,7 +50,6 @@ fun AsyncImageProxy(
AsyncImage(
model = model.url,
contentDescription = contentDescription,
imageLoader = LocalImageLoader.current,
modifier = modifier,
placeholder = placeholder,
error = error,
@@ -55,14 +64,9 @@ fun AsyncImageProxy(
filterQuality = filterQuality
)
} else {
val imgPx = with(LocalDensity.current) { model.size.toPx().toInt() }
val base64 = Base64.getUrlEncoder().encodeToString(model.url.toByteArray())
val extension = model.url.split(".").lastOrNull()
AsyncImage(
model = "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64",
model = model.proxyUrl(),
contentDescription = contentDescription,
imageLoader = LocalImageLoader.current,
modifier = modifier,
placeholder = placeholder,
error = error,

View File

@@ -25,6 +25,8 @@ import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.R
import androidx.compose.ui.res.stringResource
@Composable
fun ExpandableRichTextViewer(
@@ -67,7 +69,7 @@ fun ExpandableRichTextViewer(
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Show More", color = Color.White)
Text(text = stringResource(R.string.show_more), color = Color.White)
}
}
}

View File

@@ -22,6 +22,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
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
@@ -68,7 +69,7 @@ fun InvoicePreview(lnInvoice: String) {
)
Text(
text = "Lightning Invoice",
text = stringResource(R.string.lightning_invoice),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
@@ -79,7 +80,9 @@ fun InvoicePreview(lnInvoice: String) {
amount?.let {
Text(
text = "${NumberFormat.getInstance().format(amount)} sats",
text = "${
NumberFormat.getInstance().format(amount)
} ${stringResource(id = R.string.sats)}",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier = Modifier
@@ -90,7 +93,9 @@ fun InvoicePreview(lnInvoice: String) {
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$lnInvoice"))
@@ -102,7 +107,7 @@ fun InvoicePreview(lnInvoice: String) {
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Pay", color = Color.White, fontSize = 20.sp)
Text(text = stringResource(R.string.pay), color = Color.White, fontSize = 20.sp)
}
}
}

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
@@ -73,7 +74,7 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
)
Text(
text = "Lightning Tips",
text = stringResource(R.string.lightning_tips),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
@@ -86,13 +87,13 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
var amount by remember { mutableStateOf(1000L) }
OutlinedTextField(
label = { Text(text = "Note to Receiver") },
label = { Text(text = stringResource(R.string.note_to_receiver)) },
modifier = Modifier.fillMaxWidth(),
value = message,
onValueChange = { message = it },
placeholder = {
Text(
text = "Thank you so much!",
text = stringResource(R.string.thank_you_so_much),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
@@ -103,7 +104,7 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
)
OutlinedTextField(
label = { Text(text = "Amount in Sats") },
label = { Text(text = stringResource(R.string.amount_in_sats)) },
modifier = Modifier.fillMaxWidth(),
value = amount.toString(),
onValueChange = {
@@ -152,7 +153,7 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Send Sats", color = Color.White, fontSize = 20.sp)
Text(text = stringResource(R.string.send_sats), color = Color.White, fontSize = 20.sp)
}
}
}

View File

@@ -2,24 +2,44 @@ package com.vitorpamplona.amethyst.ui.components
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.shape.RoundedCornerShape
import androidx.compose.material.LocalContentColor
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.text.Paragraph
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.RichText
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.currentRichTextStyle
import com.halilibo.richtext.ui.material.MaterialRichText
import com.halilibo.richtext.ui.resolveDefaults
import com.halilibo.richtext.ui.string.RichTextString
import com.halilibo.richtext.ui.string.RichTextStringStyle
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.Nip19
@@ -60,61 +80,82 @@ fun RichTextViewer(
accountViewModel: AccountViewModel,
navController: NavController,
) {
Column(modifier = modifier.animateContentSize()) {
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
paragraph.split(' ').forEach { word: String ->
if (content.startsWith("# ") || content.contains("##") || content.contains("```")) {
var richTextStyle by remember { mutableStateOf(RichTextStyle().resolveDefaults()) }
if (canPreview) {
// Explicit URL
val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
ZoomableImageView(word)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(word)
MaterialRichText(
style = RichTextStyle().resolveDefaults().copy(
stringStyle = richTextStyle.stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
)
)
),
) {
Markdown(
content = content,
markdownParseOptions = MarkdownParseOptions.Default,
)
}
} else {
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
paragraph.split(' ').forEach { word: String ->
if (canPreview) {
// Explicit URL
val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
ZoomableImageView(word)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(word)
} else {
UrlPreview(word, word)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
UrlPreview(word, word)
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
} else {
if (isValidURL(word)) {
ClickableUrl("$word ", word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
ClickableUrl(word, "https://$word")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
if (isValidURL(word)) {
ClickableUrl("$word ", word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
ClickableUrl(word, "https://$word")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
}
}
}

View File

@@ -27,6 +27,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
@@ -34,6 +35,7 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.core.os.ConfigurationCompat
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.lang.ResultOrError
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -110,17 +112,17 @@ fun TranslateableRichTextViewer(
val annotatedTranslationString = buildAnnotatedString {
withStyle(clickableTextStyle) {
pushStringAnnotation("langSettings", true.toString())
append("Auto")
append(stringResource(R.string.auto))
}
append("-translated from ")
append("-${stringResource(R.string.translated_from)} ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", true.toString())
append(Locale(source).displayName)
}
append(" to ")
append(" ${stringResource(R.string.to)} ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", false.toString())
@@ -163,7 +165,7 @@ fun TranslateableRichTextViewer(
Spacer(modifier = Modifier.size(10.dp))
Text("Never translate from ${Locale(source).displayName}")
Text(stringResource(R.string.never_translate_from) + "${Locale(source).displayName}")
}
Divider()
DropdownMenuItem(onClick = {
@@ -181,7 +183,14 @@ fun TranslateableRichTextViewer(
Spacer(modifier = Modifier.size(10.dp))
Text("Show in ${Locale(source).displayName} first")
// TODO : Rashed translate this
Text(
"${stringResource(R.string.show_in)} ${Locale(source).displayName} ${
stringResource(
R.string.first
)
}"
)
}
DropdownMenuItem(onClick = {
accountViewModel.prefer(source, target, target)
@@ -198,7 +207,7 @@ fun TranslateableRichTextViewer(
Spacer(modifier = Modifier.size(10.dp))
Text("Show in ${Locale(target).displayName} first")
Text("${stringResource(id = R.string.show_in)} ${Locale(target).displayName} ${R.string.first}")
}
Divider()
@@ -221,7 +230,7 @@ fun TranslateableRichTextViewer(
Spacer(modifier = Modifier.size(10.dp))
Text("Always translate to ${lang.displayName}")
Text("${stringResource(R.string.always_translate_to)}${lang.displayName}")
}
}
}

View File

@@ -8,32 +8,45 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.baha.url.preview.IUrlPreviewCallback
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun UrlPreview(url: String, urlText: String, showUrlIfError: Boolean = true) {
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(UrlPreviewState.Loading) }
fun UrlPreview(url: String, urlText: String) {
val default = UrlCachedPreviewer.cache[url]?.let { UrlPreviewState.Loaded(it) } ?: UrlPreviewState.Loading
var context = LocalContext.current
var urlPreviewState by remember { mutableStateOf(default) }
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
LaunchedEffect(url) {
withContext(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
if (urlInfo.allFetchComplete() && urlInfo.url == url)
urlPreviewState = UrlPreviewState.Loaded(urlInfo)
else
urlPreviewState = UrlPreviewState.Empty
}
if (urlPreviewState == UrlPreviewState.Loading) {
withContext(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
if (urlInfo.allFetchComplete() && urlInfo.url == url)
urlPreviewState = UrlPreviewState.Loaded(urlInfo)
else
urlPreviewState = UrlPreviewState.Empty
}
override fun onFailed(throwable: Throwable) {
urlPreviewState = UrlPreviewState.Error("Error parsing preview for ${url}: ${throwable.message}")
}
})
override fun onFailed(throwable: Throwable) {
urlPreviewState = UrlPreviewState.Error(
context.getString(
R.string.error_parsing_preview_for,
url,
throwable.message
)
)
}
})
}
}
}
@@ -43,9 +56,7 @@ fun UrlPreview(url: String, urlText: String, showUrlIfError: Boolean = true) {
UrlPreviewCard(url, state.previewInfo)
}
else -> {
if (showUrlIfError) {
ClickableUrl(urlText, url)
}
ClickableUrl(urlText, url)
}
}
}

View File

@@ -15,10 +15,12 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.R
@Composable
fun UrlPreviewCard(
@@ -36,7 +38,7 @@ fun UrlPreviewCard(
Column {
AsyncImage(
model = previewInfo.image,
contentDescription = "Preview Card Image for ${url}",
contentDescription = stringResource(R.string.preview_card_image_for, url),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)

View File

@@ -17,7 +17,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
@Composable
fun ZoomableAsyncImage(imageUrl: String) {
@@ -46,14 +48,16 @@ fun ZoomableAsyncImage(imageUrl: String) {
) {
AsyncImage(
model = imageUrl,
contentDescription = "Profile Image",
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxSize().graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY
),
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY
),
)
}
}

View File

@@ -10,11 +10,11 @@ object GlobalFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
override fun feed() = LocalCache.notes.values
.filter { account.isAcceptable(it) }
.filter {
(it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()) ||
(it.event is ChannelMessageEvent && (it.event as ChannelMessageEvent).replyTos.isEmpty())
}
.filter { account.isAcceptable(it) }
.sortedBy { it.event?.createdAt }
.reversed()

View File

@@ -17,6 +17,7 @@ object HomeConversationsFeedFilter: FeedFilter<Note>() {
(it.event is TextNoteEvent || it.event is RepostEvent)
&& it.author in user.follows
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
&& it.author?.let { !HomeNewThreadFeedFilter.account.isHidden(it) } ?: true
&& !it.isNewThread()
}
.sortedBy { it.event?.createdAt }

View File

@@ -3,6 +3,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.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import nostr.postr.events.TextNoteEvent
@@ -14,9 +15,10 @@ object HomeNewThreadFeedFilter: FeedFilter<Note>() {
return LocalCache.notes.values
.filter {
(it.event is TextNoteEvent || it.event is RepostEvent)
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent)
&& it.author in user.follows
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
&& it.author?.let { !account.isHidden(it) } ?: true
&& it.isNewThread()
}
.sortedBy { it.event?.createdAt }

View File

@@ -25,16 +25,16 @@ object NotificationFeedFilter: FeedFilter<Note>() {
}
.filter {
it.event !is TextNoteEvent
||
(
it.event is TextNoteEvent
&&
(
it.replyTo?.lastOrNull()?.author == account.userProfile()
||
account.userProfile() in it.directlyCiteUsers()
)
)
||
(
it.event is TextNoteEvent
&&
(
it.replyTo?.any { it.author == account.userProfile() } == true
||
account.userProfile() in it.directlyCiteUsers()
)
)
}
.filter {
it.event !is ReactionEvent

View File

@@ -36,6 +36,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
@@ -201,7 +202,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
placeholder = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
fallback = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
error = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
contentDescription = "Profile Image",
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(34.dp)
.height(34.dp)

View File

@@ -51,6 +51,8 @@ import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalContext
@Composable
fun DrawerContent(navController: NavHostController,
@@ -111,7 +113,7 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
if (banner != null && banner.isNotBlank()) {
AsyncImageProxy(
model = ResizeImage(banner, 150.dp),
contentDescription = "Profile Image",
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
@@ -120,7 +122,7 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = "Profile Banner",
contentDescription = stringResource(R.string.profile_banner),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
@@ -131,7 +133,7 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
Column(modifier = modifier) {
AsyncImageProxy(
model = ResizeImage(accountUser.profilePicture(), 100.dp),
contentDescription = "Profile Image",
contentDescription = stringResource(id = R.string.profile_image),
placeholder = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
fallback = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
error = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
@@ -187,11 +189,11 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
})) {
Row() {
Text("${accountUserFollows.follows.size}", fontWeight = FontWeight.Bold)
Text(" Following")
Text(stringResource(R.string.following))
}
Row(modifier = Modifier.padding(start = 10.dp)) {
Text("${accountUserFollows.followers.size}", fontWeight = FontWeight.Bold)
Text(" Followers")
Text(stringResource(R.string.followers))
}
}
}
@@ -216,7 +218,7 @@ fun ListContent(
scaffoldState,
"User/${accountUser.pubkeyHex}",
Route.Profile.icon,
"Profile"
stringResource(R.string.profile)
)
Divider(
@@ -231,14 +233,14 @@ fun ListContent(
}
})) {
Text(
text = "Security Filters",
text = stringResource(R.string.security_filters),
fontSize = 18.sp,
fontWeight = W500
)
}
Row(modifier = Modifier.clickable(onClick = { accountViewModel.logOff() })) {
Text(
text = "Log out",
text = stringResource(R.string.log_out),
modifier = Modifier.padding(vertical = 15.dp),
fontSize = 18.sp,
fontWeight = W500

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -14,9 +15,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -29,13 +32,14 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
modifier = Modifier.padding(
start = 20.dp,
end = 20.dp,
bottom = 25.dp,
bottom = 8.dp,
top = 15.dp
),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "Referenced post not found",
text = stringResource(R.string.post_not_found),
modifier = Modifier.padding(30.dp),
color = Color.Gray,
)
@@ -59,15 +63,13 @@ fun HiddenNote(reports: Set<Note>, loggedIn: User, modifier: Modifier = Modifier
Row(
modifier = Modifier.padding(
start = 20.dp,
end = 20.dp,
bottom = 25.dp,
top = 15.dp
end = 20.dp
),
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(30.dp)) {
Text(
text = "Post was flagged as inappropriate by",
text = stringResource(R.string.post_was_flagged_as_inappropriate_by),
color = Color.Gray,
)
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
@@ -91,13 +93,12 @@ fun HiddenNote(reports: Set<Note>, loggedIn: User, modifier: Modifier = Modifier
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Show Anyway", color = Color.White)
Text(text = stringResource(R.string.show_anyway), color = Color.White)
}
}
}
Divider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = 0.25.dp
)
}

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
@@ -38,6 +39,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
@@ -71,9 +73,9 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
val noteEvent = note.event
val description = if (noteEvent is ChannelCreateEvent) {
"Channel created"
stringResource(R.string.channel_created)
} else if (noteEvent is ChannelMetadataEvent) {
"Channel Information changed to "
"${stringResource(R.string.channel_information_changed_to)} "
} else {
noteEvent?.content
}
@@ -97,7 +99,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
Text(
" Public Chat",
" ${stringResource(R.string.public_chat)}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
@@ -158,7 +160,7 @@ fun ChannelName(
placeholder = channelPicturePlaceholder,
fallback = channelPicturePlaceholder,
error = channelPicturePlaceholder,
contentDescription = "Channel Image",
contentDescription = stringResource(R.string.channel_image),
modifier = Modifier
.width(55.dp)
.height(55.dp)
@@ -182,6 +184,8 @@ fun ChannelName(
hasNewMessages: Boolean,
onClick: () -> Unit
) {
val context = LocalContext.current
Column(modifier = Modifier.clickable(onClick = onClick) ) {
Row(
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)
@@ -198,7 +202,7 @@ fun ChannelName(
channelLastTime?.let {
Text(
timeAgo(channelLastTime),
timeAgo(channelLastTime, context),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f)
)
}
@@ -217,7 +221,7 @@ fun ChannelName(
)
else
Text(
"Referenced event not found",
stringResource(R.string.referenced_event_not_found),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,

View File

@@ -45,6 +45,7 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
@@ -53,6 +54,7 @@ import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
@@ -193,7 +195,7 @@ fun ChatroomMessageCompose(
placeholder = BitmapPainter(RoboHashCache.get(context, author.pubkeyHex)),
fallback = BitmapPainter(RoboHashCache.get(context, author.pubkeyHex)),
error = BitmapPainter(RoboHashCache.get(context, author.pubkeyHex)),
contentDescription = "Profile Image",
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(25.dp)
.height(25.dp)
@@ -238,15 +240,19 @@ fun ChatroomMessageCompose(
Row(verticalAlignment = Alignment.CenterVertically) {
val event = note.event
if (event is ChannelCreateEvent) {
Text(text = "${note.author?.toBestDisplayName()} created " +
"${event.channelInfo.name ?: ""} with " +
"description of '${event.channelInfo.about ?: ""}', " +
"and picture '${event.channelInfo.picture ?: ""}'")
Text(text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.created)} " + (event.channelInfo.name
?: "") +" ${stringResource(R.string.with_description_of)} '" + (event.channelInfo.about
?: "") + "', ${stringResource(R.string.and_picture)} '" + (event.channelInfo.picture
?: "") + "'"
)
} else if (event is ChannelMetadataEvent) {
Text(text = "${note.author?.toBestDisplayName()} changed " +
"chat name to '${event.channelInfo.name ?: ""}', " +
"description to '${event.channelInfo.about ?: ""}', " +
"and picture to '${event.channelInfo.picture ?: ""}'")
Text(text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (event.channelInfo.name
?: "") + "$', {stringResource(R.string.description_to)} '" + (event.channelInfo.about
?: "") + "', ${stringResource(R.string.and_picture_to)} '" + (event.channelInfo.picture
?: "") + "'"
)
} else {
val eventContent = accountViewModel.decrypt(note)
@@ -266,7 +272,7 @@ fun ChatroomMessageCompose(
)
} else {
TranslateableRichTextViewer(
"Could Not decrypt the message",
stringResource(R.string.could_not_decrypt_the_message),
true,
Modifier,
note.event?.tags,
@@ -289,7 +295,7 @@ fun ChatroomMessageCompose(
) {
Row() {
Text(
timeAgoShort(note.event?.createdAt),
timeAgoShort(note.event?.createdAt, context),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 12.sp
)
@@ -343,7 +349,7 @@ private fun RelayBadges(baseNote: Note) {
placeholder = BitmapPainter(RoboHashCache.get(ctx, url)),
fallback = BitmapPainter(RoboHashCache.get(ctx, url)),
error = BitmapPainter(RoboHashCache.get(ctx, url)),
contentDescription = "Relay Icon",
contentDescription = stringResource(id = R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.fillMaxSize(1f)

View File

@@ -0,0 +1,216 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
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.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MultiSetCompose(multiSetCard: MultiSetCard, modifier: Modifier = Modifier, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by multiSetCard.note.live().metadata.observeAsState()
val note = noteState?.note
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val context = LocalContext.current.applicationContext
val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) }
if (note == null) {
BlankNote(Modifier, false)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = multiSetCard) {
isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
NotificationCache.markAsRead(routeForLastRead, multiSetCard.createdAt, context)
}
var backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
Column(
modifier = Modifier
.background(backgroundColor)
.combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
} else {
note.channel?.let {
navController.navigate("Channel/${it.idHex}")
}
}
},
onLongClick = { popupExpanded = true }
)
) {
Row(modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp)
) {
Column(Modifier.fillMaxWidth()) {
if (multiSetCard.zapEvents.isNotEmpty()) {
Row(Modifier.fillMaxWidth()) {
// Draws the like picture outside the boosted card.
Box(
modifier = Modifier
.width(55.dp)
.padding(0.dp)
) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = "Zaps",
tint = BitcoinOrange,
modifier = Modifier
.size(25.dp)
.align(Alignment.TopEnd)
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
multiSetCard.zapEvents.forEach {
NoteAuthorPicture(
note = it.key,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
}
}
if (multiSetCard.boostEvents.isNotEmpty()) {
Row(Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.width(55.dp)
.padding(end = 4.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Modifier
.size(18.dp)
.align(Alignment.TopEnd),
tint = Color.Unspecified
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
multiSetCard.boostEvents.forEach {
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
}
}
if (multiSetCard.likeEvents.isNotEmpty()) {
Row(Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.width(55.dp)
.padding(end = 5.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Modifier
.size(16.dp)
.align(Alignment.TopEnd),
tint = Color.Unspecified
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
multiSetCard.likeEvents.forEach {
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
}
}
Row(Modifier.fillMaxWidth()) {
Box(modifier = Modifier
.width(65.dp)
.padding(0.dp)) {
}
NoteCompose(
baseNote = note,
routeForLastRead = null,
modifier = Modifier.padding(top = 5.dp),
isBoostedNote = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
navController = navController
)
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
}
}
}

View File

@@ -94,7 +94,8 @@ fun ObserveDisplayNip05Status(baseUser: User) {
user.nip05()?.let { nip05 ->
if (nip05.split("@").size == 2) {
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05.split("@")[0] != "_")
Text(

View File

@@ -18,12 +18,15 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
@@ -35,6 +38,7 @@ import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
@@ -42,9 +46,11 @@ import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.UrlPreviewCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Following
import kotlin.time.ExperimentalTime
import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.TextNoteEvent
@OptIn(ExperimentalFoundationApi::class)
@@ -185,7 +191,7 @@ fun NoteCompose(
placeholder = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
fallback = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
error = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
contentDescription = "Group Picture",
contentDescription = stringResource(R.string.group_picture),
modifier = Modifier
.width(30.dp)
.height(30.dp)
@@ -234,7 +240,7 @@ fun NoteCompose(
}
Text(
timeAgo(noteEvent.createdAt),
timeAgo(noteEvent.createdAt, context = context),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
@@ -257,7 +263,7 @@ fun NoteCompose(
if (note.author != null && !makeItShort) {
ObserveDisplayNip05Status(note.author!!)
}
Spacer(modifier = Modifier.height(3.dp))
if (noteEvent is TextNoteEvent && (note.replyTo != null || note.mentions != null)) {
@@ -315,26 +321,81 @@ fun NoteCompose(
)
}
} else if (noteEvent is ReportEvent) {
val reportType = noteEvent.reportType.map {
when (it) {
ReportEvent.ReportType.EXPLICIT -> "Explicit Content"
ReportEvent.ReportType.SPAM -> "Spam"
ReportEvent.ReportType.IMPERSONATION -> "Impersonation"
ReportEvent.ReportType.ILLEGAL -> "Illegal Behavior"
else -> "Unknown"
val reportType = (noteEvent.reportedPost + noteEvent.reportedAuthor).map {
when (it.reportType) {
ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content)
ReportEvent.ReportType.NUDITY -> stringResource(R.string.nudity)
ReportEvent.ReportType.PROFANITY -> stringResource(R.string.profanity_hateful_speech)
ReportEvent.ReportType.SPAM -> stringResource(R.string.spam)
ReportEvent.ReportType.IMPERSONATION -> stringResource(R.string.impersonation)
ReportEvent.ReportType.ILLEGAL -> stringResource(R.string.illegal_behavior)
else -> stringResource(R.string.unknown)
}
}.joinToString(", ")
}.toSet().joinToString(", ")
Text(
text = reportType
)
Divider(
modifier = Modifier.padding(top = 40.dp),
thickness = 0.25.dp
)
} else if (noteEvent is LongTextNoteEvent) {
Row(
modifier = Modifier
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
) {
Column {
noteEvent.image?.let {
AsyncImage(
model = noteEvent.image,
contentDescription = stringResource(
R.string.preview_card_image_for,
noteEvent.image
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
}
noteEvent.title?.let {
Text(
text = it,
style = MaterialTheme.typography.body2,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
noteEvent.summary?.let {
Text(
text = it,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
ReactionsRow(note, accountViewModel)
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
} else {
val eventContent = noteEvent.content
val eventContent = accountViewModel.decrypt(note)
val canPreview = note.author == account.userProfile()
|| (note.author?.let { account.userProfile().isFollowing(it) } ?: true )
|| !noteForReports.hasAnyReports()
@@ -391,7 +452,7 @@ private fun RelayBadges(baseNote: Note) {
placeholder = BitmapPainter(RoboHashCache.get(ctx, url)),
fallback = BitmapPainter(RoboHashCache.get(ctx, url)),
error = BitmapPainter(RoboHashCache.get(ctx, url)),
contentDescription = "Relay Icon",
contentDescription = stringResource(R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.fillMaxSize(1f)
@@ -460,7 +521,7 @@ fun NoteAuthorPicture(
if (author == null) {
Image(
painter = BitmapPainter(RoboHashCache.get(ctx, "ohnothisauthorisnotfound")),
contentDescription = "Unknown Author",
contentDescription = stringResource(R.string.unknown_author),
modifier = pictureModifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
@@ -485,13 +546,15 @@ fun UserPicture(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UserPicture(
baseUser: User,
baseUserAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier,
onClick: ((User) -> Unit)? = null
onClick: ((User) -> Unit)? = null,
onLongClick: ((User) -> Unit)? = null
) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
@@ -505,7 +568,7 @@ fun UserPicture(
AsyncImageProxy(
model = ResizeImage(user.profilePicture(), size),
contentDescription = "Profile Image",
contentDescription = stringResource(id = R.string.profile_image),
placeholder = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
fallback = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
error = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
@@ -514,7 +577,9 @@ fun UserPicture(
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.run {
if (onClick != null)
if (onClick != null && onLongClick != null)
this.combinedClickable(onClick = { onClick(user) }, onLongClick = { onLongClick(user) } )
else if (onClick != null)
this.clickable(onClick = { onClick(user) } )
else
this
@@ -544,7 +609,7 @@ fun UserPicture(
Icon(
painter = painterResource(R.drawable.ic_verified),
"Following",
stringResource(id = R.string.following),
modifier = Modifier.fillMaxSize(),
tint = Following
)
@@ -564,17 +629,17 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
onDismissRequest = onDismiss
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")); onDismiss() }) {
Text("Copy Text")
Text(stringResource(R.string.copy_text))
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.author?.pubkeyNpub() ?: "")); onDismiss() }) {
Text("Copy User PubKey")
Text(stringResource(R.string.copy_user_pubkey))
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.idNote())); onDismiss() }) {
Text("Copy Note ID")
Text(stringResource(R.string.copy_note_id))
}
Divider()
DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) {
Text("Broadcast")
Text(stringResource(R.string.broadcast))
}
if (note.author == accountViewModel.accountLiveData.value?.account?.userProfile()) {
Divider()
@@ -592,7 +657,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
)
}; onDismiss()
}) {
Text("Block & Hide Author")
Text(stringResource(R.string.block_hide_user))
}
Divider()
DropdownMenuItem(onClick = {
@@ -600,28 +665,35 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Spam / Scam")
Text(stringResource(R.string.report_spam_scam))
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.PROFANITY);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text(stringResource(R.string.report_hateful_speech))
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.IMPERSONATION);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Impersonation")
Text(stringResource(R.string.report_impersonation))
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.EXPLICIT);
accountViewModel.report(note, ReportEvent.ReportType.NUDITY);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Explicit Content")
Text(stringResource(R.string.report_nudity_porn))
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.ILLEGAL);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Illegal Behaviour")
Text(stringResource(R.string.report_illegal_behaviour))
}
}
}

View File

@@ -47,6 +47,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
@@ -141,7 +142,7 @@ fun ReplyReaction(
scope.launch {
Toast.makeText(
context,
"Login with a Private key to be able to reply",
context.getString(R.string.login_with_a_private_key_to_be_able_to_reply),
Toast.LENGTH_SHORT
).show()
}
@@ -193,7 +194,7 @@ private fun BoostReaction(
scope.launch {
Toast.makeText(
context,
"Login with a Private key to be able to boost posts",
context.getString(R.string.login_with_a_private_key_to_be_able_to_boost_posts),
Toast.LENGTH_SHORT
).show()
}
@@ -264,7 +265,7 @@ fun LikeReaction(
scope.launch {
Toast.makeText(
context,
"Login with a Private key to like Posts",
context.getString(R.string.login_with_a_private_key_to_like_posts),
Toast.LENGTH_SHORT
).show()
}
@@ -329,7 +330,7 @@ fun ZapReaction(
Toast
.makeText(
context,
"No Zap Amount Setup. Long Press to change",
context.getString(R.string.no_zap_amount_setup_long_press_to_change),
Toast.LENGTH_SHORT
)
.show()
@@ -339,7 +340,7 @@ fun ZapReaction(
Toast
.makeText(
context,
"Login with a Private key to be able to send Zaps",
context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps),
Toast.LENGTH_SHORT
)
.show()
@@ -386,14 +387,14 @@ fun ZapReaction(
if (zappedNote?.isZappedBy(account.userProfile()) == true) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = "Zaps",
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(20.dp),
tint = BitcoinOrange
)
} else {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = "Zaps",
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier.size(20.dp),
tint = grayTint
)
@@ -433,7 +434,7 @@ private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier)
.diskCachePolicy(CachePolicy.DISABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build(),
contentDescription = "View count",
contentDescription = stringResource(R.string.view_count),
modifier = Modifier.height(24.dp),
colorFilter = ColorFilter.tint(grayTint)
)
@@ -461,7 +462,7 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
backgroundColor = MaterialTheme.colors.primary
)
) {
Text("Boost", color = Color.White, textAlign = TextAlign.Center)
Text(stringResource(R.string.boost), color = Color.White, textAlign = TextAlign.Center)
}
Button(
@@ -473,7 +474,7 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
backgroundColor = MaterialTheme.colors.primary
)
) {
Text("Quote", color = Color.White, textAlign = TextAlign.Center)
Text(stringResource(R.string.quote), color = Color.White, textAlign = TextAlign.Center)
}
}
}
@@ -640,7 +641,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
label = { Text(text = "New Amount in Sats") },
label = { Text(text = stringResource(R.string.new_amount_in_sats)) },
value = postViewModel.nextAmount,
onValueChange = {
postViewModel.nextAmount = it
@@ -656,7 +657,9 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
)
},
singleLine = true,
modifier = Modifier.padding(end = 10.dp).weight(1f)
modifier = Modifier
.padding(end = 10.dp)
.weight(1f)
)
Button(
@@ -667,7 +670,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Add", color = Color.White)
Text(text = stringResource(R.string.add), color = Color.White)
}
}
}

View File

@@ -17,10 +17,13 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.time.Instant
@@ -38,6 +41,8 @@ fun RelayCompose(
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val context = LocalContext.current
Column() {
Row(
modifier = Modifier
@@ -58,13 +63,13 @@ fun RelayCompose(
)
Text(
timeAgo(relay.lastEvent),
timeAgo(relay.lastEvent, context = context),
maxLines = 1
)
}
Text(
"${relay.counter} posts received",
"${relay.counter} ${stringResource(R.string.posts_received)}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -99,7 +104,7 @@ fun AddRelayButton(onClick: () -> Unit) {
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Add", color = Color.White)
Text(text = stringResource(id = R.string.add), color = Color.White)
}
}
@@ -115,7 +120,7 @@ fun RemoveRelayButton(onClick: () -> Unit) {
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Remove", color = Color.White)
Text(text = stringResource(R.string.remove), color = Color.White)
}
}

View File

@@ -7,6 +7,9 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -15,6 +18,7 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -37,7 +41,7 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
val repliesToDisplay = if (expanded) mentions else mentions.take(2)
Text(
"replying to ",
stringResource(R.string.replying_to),
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@@ -62,7 +66,7 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
)
} else if (idx < repliesToDisplay.size - 1) {
Text(
" and ",
" ${stringResource(R.string.and)} ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@@ -76,7 +80,7 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
)
} else if (idx < repliesToDisplay.size) {
Text(
" and ",
" ${stringResource(R.string.and)} ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@@ -88,7 +92,7 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
)
Text(
" others",
" ${stringResource(R.string.others)}",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@@ -128,7 +132,7 @@ fun ReplyInformationChannel(replyTo: List<Note>?,
FlowRow() {
Text(
"in channel ",
stringResource(R.string.in_channel),
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@@ -142,7 +146,7 @@ fun ReplyInformationChannel(replyTo: List<Note>?,
if (mentions != null && mentions.isNotEmpty()) {
if (replyTo != null && replyTo.isNotEmpty()) {
Text(
"replying to ",
stringResource(id = R.string.replying_to),
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@@ -168,7 +172,7 @@ fun ReplyInformationChannel(replyTo: List<Note>?,
)
} else if (idx < mentionSet.size - 1) {
Text(
" and ",
" ${stringResource(id = R.string.add)} ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)

View File

@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.ui.note
import android.content.Context
import android.text.format.DateUtils
import com.vitorpamplona.amethyst.R
fun timeAgo(mills: Long?): String {
fun timeAgo(mills: Long?, context : Context): String {
if (mills == null) return " "
if (mills == 0L) return " • never"
if (mills == 0L) return "${context.getString(R.string.never)}"
var humanReadable = DateUtils.getRelativeTimeSpanString(
mills * 1000,
@@ -13,16 +15,16 @@ fun timeAgo(mills: Long?): String {
DateUtils.FORMAT_ABBREV_ALL
).toString()
if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) {
humanReadable = "now";
humanReadable = context.getString(R.string.now);
}
return "" + humanReadable
.replace(" hr. ago", "h")
.replace(" min. ago", "m")
.replace(" days ago", "d")
.replace(" hr. ago", context.getString(R.string.h))
.replace(" min. ago", context.getString(R.string.m))
.replace(" days ago", context.getString(R.string.d))
}
fun timeAgoShort(mills: Long?): String {
fun timeAgoShort(mills: Long?, context: Context): String {
if (mills == null) return " "
var humanReadable = DateUtils.getRelativeTimeSpanString(
@@ -32,13 +34,13 @@ fun timeAgoShort(mills: Long?): String {
DateUtils.FORMAT_ABBREV_ALL
).toString()
if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) {
humanReadable = "now";
humanReadable = context.getString(R.string.now);
}
return humanReadable
}
fun timeAgoLong(mills: Long?): String {
fun timeAgoLong(mills: Long?, context: Context): String {
if (mills == null) return " "
var humanReadable = DateUtils.getRelativeTimeSpanString(
@@ -48,7 +50,7 @@ fun timeAgoLong(mills: Long?): String {
DateUtils.FORMAT_SHOW_TIME
).toString()
if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) {
humanReadable = "now";
humanReadable = context.getString(R.string.now);
}
return humanReadable

View File

@@ -15,11 +15,14 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -94,7 +97,7 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
verticalArrangement = Arrangement.Center
) {
Text(
showAmount(amount) + " sats",
"${showAmount(amount)} ${stringResource(R.string.sats)}",
color = BitcoinOrange,
fontSize = 20.sp,
fontWeight = FontWeight.W500,

View File

@@ -24,10 +24,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
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.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.ZapSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -94,9 +96,11 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, modifier: Modifier = Modifier, isInner
.padding(0.dp)) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = "Zaps",
contentDescription = stringResource(id = R.string.zaps),
tint = BitcoinOrange,
modifier = Modifier.size(25.dp).align(Alignment.TopEnd)
modifier = Modifier
.size(25.dp)
.align(Alignment.TopEnd)
)
}
}

View File

@@ -28,11 +28,15 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalContext
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 coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton
@@ -83,7 +87,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
placeholder = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
fallback = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
error = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
contentDescription = "Profile Image",
contentDescription = stringResource(R.string.profile_image),
modifier = Modifier
.width(100.dp)
.height(100.dp)
@@ -129,7 +133,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Scan QR")
Text(text = stringResource(R.string.scan_qr))
}
}
@@ -137,7 +141,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
"Point to the QR Code",
stringResource(R.string.point_to_the_qr_code),
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 25.sp
@@ -167,7 +171,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Show QR")
Text(text = stringResource(R.string.show_qr))
}
}

View File

@@ -32,6 +32,19 @@ class ZapSetCard(val note: Note, val zapEvents: Map<Note, Note>): Card() {
override fun id() = note.idHex + "Z" + createdAt
}
class MultiSetCard(val note: Note, val boostEvents: List<Note>, val likeEvents: List<Note>, val zapEvents: Map<Note, Note>): Card() {
val createdAt = maxOf(
zapEvents.maxOfOrNull { it.value.event?.createdAt ?: 0 } ?: 0 ,
likeEvents.maxOfOrNull { it.event?.createdAt ?: 0 } ?: 0 ,
boostEvents.maxOfOrNull { it.event?.createdAt ?: 0 } ?: 0
)
override fun createdAt(): Long {
return createdAt
}
override fun id() = note.idHex + "X" + createdAt
}
class BoostSetCard(val note: Note, val boostEvents: List<Note>): Card() {
val createdAt = boostEvents.maxOf { it.event?.createdAt ?: 0 }

View File

@@ -20,6 +20,7 @@ import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.ui.note.BoostSetCompose
import com.vitorpamplona.amethyst.ui.note.LikeSetCompose
import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ZapSetCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -120,6 +121,12 @@ private fun FeedLoaded(
navController = navController,
routeForLastRead = routeForLastRead
)
is MultiSetCard -> MultiSetCompose(
item,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
}
}
}

View File

@@ -69,7 +69,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>): ViewModel() {
reactionsPerEvent.getOrPut(reactedPost, { mutableListOf() }).add(it)
}
val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) }
//val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) }
val zapsPerEvent = mutableMapOf<Note, MutableMap<Note, Note>>()
notes
@@ -84,7 +84,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>): ViewModel() {
}
}
val zapCards = zapsPerEvent.map { ZapSetCard(it.key, it.value) }
//val zapCards = zapsPerEvent.map { ZapSetCard(it.key, it.value) }
val boostsPerEvent = mutableMapOf<Note, MutableList<Note>>()
notes
@@ -95,11 +95,20 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>): ViewModel() {
boostsPerEvent.getOrPut(boostedPost, { mutableListOf() }).add(it)
}
val boostCards = boostsPerEvent.map { BoostSetCard(it.key, it.value) }
//val boostCards = boostsPerEvent.map { BoostSetCard(it.key, it.value) }
val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys
val multiCards = allBaseNotes.map {
MultiSetCard(it,
boostsPerEvent.get(it) ?: emptyList(),
reactionsPerEvent.get(it) ?: emptyList(),
zapsPerEvent.get(it) ?: emptyMap()
)
}
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is LnZapEvent }.map { NoteCard(it) }
return (reactionCards + boostCards + zapCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
return (multiCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
}
private fun updateFeed(notes: List<Card>) {
@@ -120,11 +129,9 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>): ViewModel() {
var handlerWaiting = AtomicBoolean()
@Synchronized
private fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {

View File

@@ -22,10 +22,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -122,7 +124,7 @@ fun LoadingFeed() {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Loading feed")
Text(stringResource(R.string.loading_feed))
}
}
@@ -135,12 +137,12 @@ fun FeedError(errorMessage: String, onRefresh: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Error loading replies: $errorMessage")
Text("${stringResource(R.string.error_loading_replies)} $errorMessage")
Button(
modifier = Modifier.align(Alignment.CenterHorizontally),
onClick = onRefresh
) {
Text(text = "Try again")
Text(text = stringResource(R.string.try_again))
}
}
}
@@ -154,9 +156,9 @@ fun FeedEmpty(onRefresh: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Feed is empty.")
Text(stringResource(R.string.feed_is_empty))
OutlinedButton(onClick = onRefresh) {
Text(text = "Refresh")
Text(text = stringResource(R.string.refresh))
}
}
}

View File

@@ -87,11 +87,10 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>): ViewModel() {
}
private var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {

View File

@@ -61,11 +61,9 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>): Vie
var handlerWaiting = AtomicBoolean()
@Synchronized
private fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {

View File

@@ -83,11 +83,9 @@ class RelayFeedViewModel: ViewModel() {
var handlerWaiting = AtomicBoolean()
@Synchronized
private fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {

View File

@@ -33,6 +33,7 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
@@ -50,6 +51,14 @@ import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.delay
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
@Composable
fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
@@ -192,6 +201,8 @@ fun NoteMaster(baseNote: Note,
var showHiddenNote by remember { mutableStateOf(false) }
val context = LocalContext.current
var moreActionsExpanded by remember { mutableStateOf(false) }
val noteEvent = note?.event
@@ -232,7 +243,7 @@ fun NoteMaster(baseNote: Note,
NoteUsernameDisplay(baseNote, Modifier.weight(1f))
Text(
timeAgo(noteEvent.createdAt),
timeAgo(noteEvent.createdAt, context = context),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
@@ -256,6 +267,41 @@ fun NoteMaster(baseNote: Note,
}
}
if (noteEvent is LongTextNoteEvent) {
Row(modifier = Modifier.padding(horizontal = 12.dp)) {
Column {
noteEvent.image?.let {
AsyncImage(
model = noteEvent.image,
contentDescription = stringResource(
R.string.preview_card_image_for,
noteEvent.image
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
}
noteEvent.title?.let {
Text(
text = it,
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp)
)
}
noteEvent.summary?.let {
Text(
text = it
)
}
}
}
}
Row(modifier = Modifier.padding(horizontal = 12.dp)) {
Column() {
val eventContent = note.event?.content
@@ -286,4 +332,4 @@ fun NoteMaster(baseNote: Note,
}
}
}
}
}

View File

@@ -65,11 +65,9 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>): ViewModel() {
var handlerWaiting = AtomicBoolean()
@Synchronized
private fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.Note
@@ -51,7 +52,7 @@ class AccountViewModel(private val account: Account): ViewModel() {
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
if (lud16.isNullOrBlank()) {
onError("User does not have a lightning address setup to receive sats")
onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats))
return
}

View File

@@ -47,6 +47,7 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -59,6 +60,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
@@ -183,7 +185,7 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun
modifier = Modifier.weight(1f, true),
placeholder = {
Text(
text = "reply here.. ",
text = stringResource(R.string.reply_here),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
@@ -226,7 +228,7 @@ fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel:
placeholder = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
fallback = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
error = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
contentDescription = "Profile Image",
contentDescription = context.getString(R.string.profile_image),
modifier = Modifier
.width(35.dp)
.height(35.dp)
@@ -302,7 +304,7 @@ private fun NoteCopyButton(
Icon(
tint = Color.White,
imageVector = Icons.Default.Share,
contentDescription = "Copies the Note ID to the clipboard for sharing"
contentDescription = stringResource(R.string.copies_the_note_id_to_the_clipboard_for_sharing)
)
DropdownMenu(
@@ -310,7 +312,7 @@ private fun NoteCopyButton(
onDismissRequest = { popupExpanded = false }
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.idNote())); popupExpanded = false }) {
Text("Copy Channel ID (Note) to the Clipboard")
Text(stringResource(R.string.copy_channel_id_note_to_the_clipboard))
}
}
}
@@ -339,7 +341,7 @@ private fun EditButton(account: Account, channel: Channel) {
Icon(
tint = Color.White,
imageVector = Icons.Default.EditNote,
contentDescription = "Edits the Channel Metadata"
contentDescription = stringResource(R.string.edits_the_channel_metadata)
)
}
}
@@ -359,7 +361,7 @@ private fun JoinButton(account: Account, channel: Channel, navController: NavCon
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Join", color = Color.White)
Text(text = stringResource(R.string.join), color = Color.White)
}
}
@@ -378,6 +380,6 @@ private fun LeaveButton(account: Account, channel: Channel, navController: NavCo
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Leave", color = Color.White)
Text(text = stringResource(R.string.leave), color = Color.White)
}
}

View File

@@ -16,6 +16,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -32,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.screen.ChatroomListFeedView
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.R
@OptIn(ExperimentalPagerApi::class)
@Composable
@@ -57,7 +59,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
text = {
Text(text = "Known")
Text(text = stringResource(R.string.known))
}
)
@@ -65,7 +67,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = {
Text(text = "New Requests")
Text(text = stringResource(R.string.new_requests))
}
)
}
@@ -87,31 +89,11 @@ fun TabKnown(accountViewModel: AccountViewModel, navController: NavController) {
ChatroomListKnownFeedFilter.account = account
val feedViewModel: NostrChatroomListKnownFeedViewModel = viewModel()
LaunchedEffect(Unit) {
LaunchedEffect(accountViewModel) {
NostrChatroomListDataSource.resetFilters()
feedViewModel.invalidateData()
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Chatroom List Start")
NostrChatroomListDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Chatroom List Stop")
NostrChatroomListDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
@@ -129,29 +111,9 @@ fun TabNew(accountViewModel: AccountViewModel, navController: NavController) {
ChatroomListNewFeedFilter.account = account
val feedViewModel: NostrChatroomListNewFeedViewModel = viewModel()
LaunchedEffect(Unit) {
LaunchedEffect(accountViewModel) {
NostrChatroomListDataSource.resetFilters()
feedViewModel.refresh() // refresh view
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Chatroom List Start")
NostrChatroomListDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Chatroom List Stop")
NostrChatroomListDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
feedViewModel.invalidateData() // refresh view
}
Column(Modifier.fillMaxHeight()) {

View File

@@ -39,6 +39,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDirection
@@ -47,6 +48,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -76,7 +78,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
val feedViewModel: NostrChatRoomFeedViewModel = viewModel()
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
LaunchedEffect(userId) {
feedViewModel.invalidateData()
}
@@ -169,7 +171,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
shape = RoundedCornerShape(25.dp),
placeholder = {
Text(
text = "reply here.. ",
text = stringResource(id = R.string.reply_here),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
@@ -216,7 +218,7 @@ fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navContro
placeholder = BitmapPainter(RoboHashCache.get(ctx, author.pubkeyHex)),
fallback = BitmapPainter(RoboHashCache.get(ctx, author.pubkeyHex)),
error = BitmapPainter(RoboHashCache.get(ctx, author.pubkeyHex)),
contentDescription = "Profile Image",
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(35.dp)
.height(35.dp)

View File

@@ -13,6 +13,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
@@ -20,6 +21,7 @@ import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.dal.HiddenAccountsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.UserFeedView
@@ -55,7 +57,7 @@ fun FiltersScreen(accountViewModel: AccountViewModel, navController: NavControll
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
text = {
Text(text = "Blocked Users")
Text(text = stringResource(R.string.blocked_users))
}
)
}

View File

@@ -16,6 +16,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -25,6 +26,7 @@ import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
@@ -49,34 +51,13 @@ fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController)
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
LaunchedEffect(accountViewModel) {
NostrHomeDataSource.resetFilters()
feedViewModel.invalidateData()
feedViewModelReplies.invalidateData()
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Home Start")
NostrHomeDataSource.start()
feedViewModel.invalidateData()
feedViewModelReplies.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Home Stop")
NostrHomeDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
@@ -95,7 +76,7 @@ fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController)
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
text = {
Text(text = "New Threads")
Text(text = stringResource(R.string.new_threads))
}
)
@@ -103,7 +84,7 @@ fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController)
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = {
Text(text = "Conversations")
Text(text = stringResource(R.string.conversations))
}
)
}

View File

@@ -24,7 +24,7 @@ fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavCon
NotificationFeedFilter.account = account
val feedViewModel: NotificationViewModel = viewModel()
LaunchedEffect(Unit) {
LaunchedEffect(accountViewModel) {
feedViewModel.refresh()
}

View File

@@ -31,6 +31,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -179,34 +180,34 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
) {
val tabs = listOf<@Composable() (() -> Unit)?>(
{
Text(text = "Notes")
Text(text = stringResource(R.string.notes))
},
{
Text(text = "Replies")
Text(text = stringResource(R.string.replies))
},
{
val userState by baseUser.live().follows.observeAsState()
val userFollows = userState?.user?.follows?.size ?: "--"
Text(text = "$userFollows Follows")
Text(text = "$userFollows ${stringResource(R.string.follows)}")
},
{
val userState by baseUser.live().follows.observeAsState()
val userFollows = userState?.user?.followers?.size ?: "--"
val userFollowers = userState?.user?.followers?.size ?: "--"
Text(text = "$userFollows Followers")
Text(text = "$userFollowers ${stringResource(id = R.string.followers)}")
},
{
val userState by baseUser.live().zaps.observeAsState()
val userZaps = userState?.user?.zappedAmount()
Text(text = "${showAmount(userZaps)} Zaps")
Text(text = "${showAmount(userZaps)} ${stringResource(id = R.string.zaps)}")
},
{
val userState by baseUser.live().reports.observeAsState()
val userReports = userState?.user?.reports?.values?.flatten()?.count()
Text(text = "${userReports} Reports")
Text(text = "${userReports.toString()} ${stringResource(R.string.reports)}")
},
{
val userState by baseUser.live().relays.observeAsState()
@@ -215,7 +216,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
val userStateRelayInfo by baseUser.live().relayInfo.observeAsState()
val userRelays = userStateRelayInfo?.user?.relays?.size ?: "--"
Text(text = "$userRelaysBeingUsed / $userRelays Relays")
Text(text = "$userRelaysBeingUsed / $userRelays ${stringResource(R.string.relays)}")
}
)
@@ -264,7 +265,8 @@ private fun ProfileHeader(
val accountUser = accountUserState?.user ?: return
val coroutineScope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current
Box {
DrawBanner(baseUser)
@@ -288,7 +290,7 @@ private fun ProfileHeader(
Icon(
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
imageVector = Icons.Default.MoreVert,
contentDescription = "More Options",
contentDescription = stringResource(R.string.more_options),
)
UserProfileDropDownMenu(baseUser, popupExpanded, { popupExpanded = false }, accountViewModel)
@@ -309,12 +311,23 @@ private fun ProfileHeader(
) {
UserPicture(
baseUser, navController, account.userProfile(), 100.dp,
baseUser = baseUser,
baseUserAccount = account.userProfile(),
size = 100.dp,
pictureModifier = Modifier.border(
3.dp,
MaterialTheme.colors.background,
CircleShape
)
CircleShape),
onClick = {
navController.navigate("User/${it.pubkeyHex}")
},
onLongClick = {
ResizeImage(it.info?.picture, 100.dp).proxyUrl()?.let { it1 ->
clipboardManager.setText(
AnnotatedString(it1)
)
}
}
)
Spacer(Modifier.weight(1f))
@@ -386,7 +399,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) {
Icon(
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
imageVector = Icons.Default.Link,
contentDescription = "Website",
contentDescription = stringResource(R.string.website),
modifier = Modifier.size(16.dp)
)
@@ -408,7 +421,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) {
Icon(
tint = BitcoinOrange,
imageVector = Icons.Default.Bolt,
contentDescription = "Lightning Address",
contentDescription = stringResource(R.string.lightning_address),
modifier = Modifier.size(16.dp)
)
@@ -440,26 +453,33 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) {
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DrawBanner(baseUser: User) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
val banner = user.info?.banner
val clipboardManager = LocalClipboardManager.current
if (banner != null && banner.isNotBlank()) {
AsyncImageProxy(
model = ResizeImage(banner, 125.dp),
contentDescription = "Profile Image",
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
.height(125.dp).combinedClickable(
onClick = {},
onLongClick = {
clipboardManager.setText(AnnotatedString(banner))
}
)
)
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = "Profile Banner",
contentDescription = stringResource(id = R.string.profile_banner),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
@@ -638,7 +658,7 @@ private fun NSecCopyButton(
Icon(
tint = Color.White,
imageVector = Icons.Default.Key,
contentDescription = "Copies the Nsec ID (your password) to the clipboard for backup"
contentDescription = stringResource(R.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup)
)
DropdownMenu(
@@ -646,7 +666,7 @@ private fun NSecCopyButton(
onDismissRequest = { popupExpanded = false }
) {
DropdownMenuItem(onClick = { account.loggedIn.privKey?.let { clipboardManager.setText(AnnotatedString(it.toNsec())) }; popupExpanded = false }) {
Text("Copy Private Key to the Clipboard")
Text(stringResource(R.string.copy_private_key_to_the_clipboard))
}
}
}
@@ -673,7 +693,7 @@ private fun NPubCopyButton(
Icon(
tint = Color.White,
imageVector = Icons.Default.Share,
contentDescription = "Copies the public key to the clipboard for sharing"
contentDescription = stringResource(R.string.copies_the_public_key_to_the_clipboard_for_sharing)
)
DropdownMenu(
@@ -681,7 +701,7 @@ private fun NPubCopyButton(
onDismissRequest = { popupExpanded = false }
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())); popupExpanded = false }) {
Text("Copy Public Key (NPub) to the Clipboard")
Text(stringResource(R.string.copy_public_key_npub_to_the_clipboard))
}
}
}
@@ -702,7 +722,7 @@ private fun MessageButton(user: User, navController: NavController) {
) {
Icon(
painter = painterResource(R.drawable.ic_dm),
"Send a Direct Message",
stringResource(R.string.send_a_direct_message),
modifier = Modifier.size(20.dp),
tint = Color.White
)
@@ -732,7 +752,7 @@ private fun EditButton(account: Account) {
Icon(
tint = Color.White,
imageVector = Icons.Default.EditNote,
contentDescription = "Edits the User's Metadata"
contentDescription = stringResource(R.string.edits_the_user_s_metadata)
)
}
}
@@ -749,7 +769,7 @@ fun UnfollowButton(onClick: () -> Unit) {
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Unfollow", color = Color.White)
Text(text = stringResource(R.string.unfollow), color = Color.White)
}
}
@@ -765,7 +785,7 @@ fun FollowButton(onClick: () -> Unit) {
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Follow", color = Color.White, textAlign = TextAlign.Center)
Text(text = stringResource(R.string.follow), color = Color.White, textAlign = TextAlign.Center)
}
}
@@ -781,7 +801,7 @@ fun ShowUserButton(onClick: () -> Unit) {
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Unblock", color = Color.White)
Text(text = stringResource(R.string.unblock), color = Color.White)
}
}
@@ -799,7 +819,7 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
onDismissRequest = onDismiss
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())); onDismiss() }) {
Text("Copy User ID")
Text(stringResource(R.string.copy_user_id))
}
if ( account.userProfile() != user) {
@@ -813,11 +833,11 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
)
}; onDismiss()
}) {
Text("Unblock User")
Text(stringResource(R.string.unblock_user))
}
} else {
DropdownMenuItem(onClick = { user.let { accountViewModel.hide(it, context) }; onDismiss() }) {
Text("Block & Hide User")
Text(stringResource(id = R.string.block_hide_user))
}
}
Divider()
@@ -826,28 +846,35 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
user.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Spam / Scam")
Text(stringResource(id = R.string.report_spam_scam))
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.PROFANITY);
user.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text(stringResource(R.string.report_hateful_speech))
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION);
user.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Impersonation")
Text(stringResource(id = R.string.report_impersonation))
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.EXPLICIT);
accountViewModel.report(user, ReportEvent.ReportType.NUDITY);
user.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Explicit Content")
Text(stringResource(R.string.report_nudity_porn))
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL);
user.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Illegal Behaviour")
Text(stringResource(id = R.string.report_illegal_behaviour))
}
}
}

View File

@@ -40,6 +40,7 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
@@ -67,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
@@ -81,12 +83,13 @@ fun SearchScreen(accountViewModel: AccountViewModel, navController: NavControlle
val account = accountState?.account ?: return
GlobalFeedFilter.account = account
NostrGlobalDataSource.account = account
val feedViewModel: NostrGlobalFeedViewModel = viewModel()
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
feedViewModel.invalidateData()
delay(500)
feedViewModel.invalidateData()
}
DisposableEffect(accountViewModel) {
@@ -200,7 +203,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
.defaultMinSize(minHeight = 20.dp),
placeholder = {
Text(
text = "npub, hex, username ",
text = stringResource(R.string.npub_hex_username),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
@@ -218,7 +221,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = "Clear"
contentDescription = stringResource(R.string.clear)
)
}
}

View File

@@ -38,8 +38,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
@@ -60,6 +62,7 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
val acceptedTerms = remember { mutableStateOf(false) }
var termsAcceptanceIsRequired by remember { mutableStateOf("") }
val uri = LocalUriHandler.current
val context = LocalContext.current
Column(
modifier = Modifier
@@ -81,7 +84,7 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
Image(
painterResource(id = R.drawable.amethyst),
contentDescription = "App Logo",
contentDescription = stringResource(R.string.app_logo),
modifier = Modifier.size(200.dp),
contentScale = ContentScale.Inside
)
@@ -97,12 +100,12 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
onValueChange = { key.value = it },
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go
),
placeholder = {
Text(
text = "nsec / npub / hex private key",
text = stringResource(R.string.nsec_npub_hex_private_key),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
@@ -110,7 +113,8 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (showPassword) "Show Password" else "Hide Password"
contentDescription = if (showPassword) stringResource(R.string.show_password) else stringResource(
R.string.hide_password)
)
}
},
@@ -120,7 +124,7 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
try {
accountViewModel.login(key.value.text)
} catch (e: Exception) {
errorMessage = "Invalid key"
errorMessage = context.getString(R.string.invalid_key)
}
}
)
@@ -141,10 +145,10 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
onCheckedChange = { acceptedTerms.value = it }
)
Text(text = "I accept the ")
Text(text = stringResource(R.string.i_accept_the))
ClickableText(
text = AnnotatedString("terms of use"),
text = AnnotatedString(stringResource(R.string.terms_of_use)),
onClick = { runCatching { uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
@@ -164,18 +168,18 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
Button(
onClick = {
if (!acceptedTerms.value) {
termsAcceptanceIsRequired = "Acceptance of terms is required"
termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required)
}
if (key.value.text.isBlank()) {
errorMessage = "Key is required"
errorMessage = context.getString(R.string.key_is_required)
}
if (acceptedTerms.value && key.value.text.isNotBlank()) {
try {
accountViewModel.login(key.value.text)
} catch (e: Exception) {
errorMessage = "Invalid key"
errorMessage = context.getString(R.string.invalid_key)
}
}
},
@@ -188,14 +192,14 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
backgroundColor = if (acceptedTerms.value) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Login")
Text(text = stringResource(R.string.login))
}
}
}
// The last child is glued to the bottom.
ClickableText(
text = AnnotatedString("Generate a new key"),
text = AnnotatedString(stringResource(R.string.generate_a_new_key)),
modifier = Modifier
.padding(20.dp)
.fillMaxWidth(),
@@ -203,7 +207,7 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
if (acceptedTerms.value) {
accountViewModel.newKey()
} else {
termsAcceptanceIsRequired = "Acceptance of terms is required"
termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required)
}
},
style = TextStyle(

View File

@@ -0,0 +1,176 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="point_to_the_qr_code">أشر إلى رمز QR</string>
<string name="show_qr">أظهر QR</string>
<string name="profile_image">صورة الملف الشخصي</string>
<string name="scan_qr">مسح QR</string>
<string name="show_anyway">عرض على أي حال</string>
<string name="post_was_flagged_as_inappropriate_by">تم وضع علامة على المنشور على أنه غير لائق بواسطة</string>
<string name="post_not_found">المنشور غير موجود</string>
<string name="channel_image">صورة القناة</string>
<string name="referenced_event_not_found">لم يتم العثور على الحدث المشار إليه</string>
<string name="could_not_decrypt_the_message">لا يمكن فك تشفير الرسالة</string>
<string name="group_picture">صورة المجموعة</string>
<string name="explicit_content">محتوى فاضح</string>
<string name="spam">رسائل عشوائية</string>
<string name="impersonation">التمثيل</string>
<string name="illegal_behavior">تصرف غير قانوني</string>
<string name="unknown">غير معروف</string>
<string name="relay_icon">Relay Icon</string>
<string name="unknown_author">كاتب غير معروف</string>
<string name="copy_text">نسخ النص</string>
<string name="copy_user_pubkey">نسخ المفتاح العام</string>
<string name="copy_note_id">نسخ معرف </string>
<string name="broadcast">بث</string>
<string name="block_hide_user">حظر و إخفاء المستخدم</string>
<string name="report_spam_scam">الابلاغ عن البريد العشوائي / الاحتيال</string>
<string name="report_impersonation">الإبلاغ عن التمثيل</string>
<string name="report_explicit_content">الإبلاغ عن المحتوى الصريح / الفاضح</string>
<string name="report_illegal_behaviour">الإبلاغ عن سلوك غير قانوني</string>
<string name="login_with_a_private_key_to_be_able_to_reply">قم بتسجيل الدخول باستخدام مفتاح خاص لتتمكن من الرد</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">قم بتسجيل الدخول باستخدام مفتاح خاص لتتمكن من تعزيز المشاركات</string>
<string name="login_with_a_private_key_to_like_posts">تسجيل الدخول باستخدام مفتاح خاص لإبداء الإعجاب بالمنشورات</string>
<string name="no_zap_amount_setup_long_press_to_change">لا يوجد إعداد لمبلغ Zap. اضغط لفترة طويلة للتغيير</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">قم بتسجيل الدخول باستخدام مفتاح خاص لتتمكن من إرسال Zaps</string>
<string name="zaps">Zaps</string>
<string name="view_count">مشاهدة العد</string>
<string name="boost">تعزيز</string>
<string name="quote">إقتباس</string>
<string name="new_amount_in_sats">مبلغ جديد في Sats</string>
<string name="add">إضافة</string>
<string name="replying_to">الرد على</string>
<string name="and">" و "</string>
<string name="in_channel">"في القناة "</string>
<string name="profile_banner">لافتة الملف الشخصي</string>
<string name="following">المتابعون</string>
<string name="followers">متابِعاً</string>
<string name="profile">الملف الشخصي</string>
<string name="security_filters">مرشحات الأمان</string>
<string name="log_out">تسجيل الخروج</string>
<string name="show_more">إظهار المزيد</string>
<string name="lightning_invoice">Lightning فاتورة</string>
<string name="pay">اإدفع</string>
<string name="lightning_tips">Lightning تبرع</string>
<string name="note_to_receiver">ملاحظة للمستقبل</string>
<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>
<string name="can_t_find_out_the_content_type">لا يمكن معرفة نوع المحتوى</string>
<string name="can_t_insert_the_new_content">لا يمكن إدراج المحتوى الجديد</string>
<string name="can_t_open_the_content_output_stream">لا يمكن فتح دفق إخراج المحتوى</string>
<string name="can_t_open_the_image_input_stream">لا يمكن فتح دفق إدخال الصورة</string>
<string name="there_must_be_an_uploaded_image_url_in_the_response">يجب أن يكون هناك عنوان URL للصورة التي تم تحميلها في الاستجابة</string>
<string name="channel_name">اسم القناة</string>
<string name="my_awesome_group">مجموعتي الرائعة</string>
<string name="picture_url">رابط الصورة</string>
<string name="description">الوصف</string>
<string name="about_us">معلومات عن القناة..</string>
<string name="what_s_on_your_mind">ما الذي يدور في ذهنك؟</string>
<string name="post">نشر</string>
<string name="save">حفظ</string>
<string name="create">إنشاء</string>
<string name="cancel">إلغاء</string>
<string name="failed_to_upload_the_image">فشل تحميل الصورة</string>
<string name="relay_address">Relay عنوان</string>
<string name="posts">المنشورات</string>
<string name="errors">الأخطاء</string>
<string name="home_feed">المنشورات الرئيسية</string>
<string name="private_message_feed"> الرسائل الخاصة</string>
<string name="public_chat_feed">الرسائل العامة</string>
<string name="global_feed">الموجز العام</string>
<string name="add_a_relay">أضف Relay</string>
<string name="display_name">إسم العرض</string>
<string name="my_display_name">إسم العرض الخاص بي</string>
<string name="username">اسم المستخدم</string>
<string name="my_username">اسم المستخدم الخاص بي</string>
<string name="about_me">ْعَنِّي</string>
<string name="avatar_url">رابط الصورة الزمزية</string>
<string name="banner_url">رابط الشعار</string>
<string name="website_url">رابط الموقع</string>
<string name="ln_address">LN عنوان</string>
<string name="ln_url_outdated">LN رابط (قديم)</string>
<string name="image_saved_to_the_gallery">تم حفظ الصورة في المعرض</string>
<string name="failed_to_save_the_image">فشل حفظ الصورة</string>
<string name="upload_image">تحميل الصورة</string>
<string name="uploading">جاري التحميل…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">لا يمتلك المستخدم إعداد عنوان lighting لاستقبال sats</string>
<string name="reply_here">"الرد هنا.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">نسخ معرف الملاحظة إلى الحافظة للمشاركة</string>
<string name="copy_channel_id_note_to_the_clipboard">انسخ معرف القناة (ملاحظة) إلى الحافظة</string>
<string name="edits_the_channel_metadata">تعديل البيانات الوصفية للقناة</string>
<string name="join">إنضمام</string>
<string name="known">معروف</string>
<string name="new_requests">طلبات جديدة</string>
<string name="blocked_users">المستخدمين المحظورين</string>
<string name="new_threads">المنشورات</string>
<string name="conversations">المحادثات</string>
<string name="notes">الملاحظات</string>
<string name="replies">الردود</string>
<string name="follows">المتابَعون</string>
<string name="reports">التقارير</string>
<string name="more_options">المزيد من الخيارات</string>
<string name="relays">Relays</string>
<string name="website">الموقع</string>
<string name="lightning_address">Lightning عنوان</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">نسخ معرف Nsec (كلمة المرور الخاصة بك) إلى الحافظة للنسخ الاحتياطي</string>
<string name="copy_private_key_to_the_clipboard">نسخ المفتاح الخاص إلى الحافظة</string>
<string name="copies_the_public_key_to_the_clipboard_for_sharing">نسخ المفتاح العام إلى الحافظة للمشاركة</string>
<string name="copy_public_key_npub_to_the_clipboard">انسخ المفتاح العام (NPub) إلى الحافظة</string>
<string name="send_a_direct_message">إرسال رسالة مباشرة</string>
<string name="edits_the_user_s_metadata">القيام بتحرير metadata للمستخدم</string>
<string name="follow">متابعة</string>
<string name="unblock">رفع الحظر</string>
<string name="copy_user_id">نسخ معرف المستخدم</string>
<string name="unblock_user">إلغاء حظر عن مستخدم</string>
<string name="npub_hex_username">npub, hex, username </string>
<string name="clear">نظف</string>
<string name="app_logo">شعار التطبيق</string>
<string name="nsec_npub_hex_private_key">nsec / npub / hex private key</string>
<string name="show_password">إظهار كلمة السر</string>
<string name="hide_password">اخفاء كلمة السر</string>
<string name="invalid_key">مفتاح غير صحيح</string>
<string name="i_accept_the">"اقبل شروط الاستخدام "</string>
<string name="terms_of_use">شروط الاستخدام</string>
<string name="acceptance_of_terms_is_required">يجب قبول شروط الإستخدام</string>
<string name="key_is_required">المفتاح مطلوب</string>
<string name="login">تسجيل دخول</string>
<string name="generate_a_new_key">إنشاء مفتاح جديد</string>
<string name="loading_feed">بإنتظار التحديثات</string>
<string name="error_loading_replies">خطأ في تحميل الردود:</string>
<string name="try_again">المحاولة مرة اخرى</string>
<string name="feed_is_empty">لا توجد ملاحظات.</string>
<string name="refresh">تحديث</string>
<string name="created">انشئ</string>
<string name="with_description_of">مع وصف</string>
<string name="and_picture">و صورة</string>
<string name="changed_chat_name_to">تم تغيير اسم الدردشة إلى</string>
<string name="description_to">الوصف الى</string>
<string name="and_picture_to">و الصورة الى</string>
<string name="leave">مغادرة</string>
<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="never">ابداً</string>
<string name="now">الان</string>
<string name="h">س</string>
<string name="m">د</string>
<string name="d">يوم</string>
<string name="nudity">عُري</string>
<string name="profanity_hateful_speech">الألفاظ النابية / خطاب كراهية</string>
<string name="report_hateful_speech">أبلغ عن كلام يحض على الكراهية</string>
<string name="report_nudity_porn">الإبلاغ عن عُري / إباحي</string>
<string name="others">آخرون</string>
</resources>

View File

@@ -0,0 +1,176 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="point_to_the_qr_code">Aponte para o código QR</string>
<string name="show_qr">Mostrar QR</string>
<string name="profile_image">Imagem de perfil</string>
<string name="scan_qr">Escanear QR</string>
<string name="show_anyway">Mostrar de qualquer maneira</string>
<string name="post_was_flagged_as_inappropriate_by">A postagem foi sinalizada como imprópria por</string>
<string name="post_not_found">postagem não encontrada</string>
<string name="channel_image">Imagem do Canal</string>
<string name="referenced_event_not_found">Evento referenciado não encontrado</string>
<string name="could_not_decrypt_the_message">Não foi possível descriptografar a mensagem</string>
<string name="group_picture">Imagem do grupo</string>
<string name="explicit_content">Conteúdo explícito</string>
<string name="spam">Spam</string>
<string name="impersonation">Representação</string>
<string name="illegal_behavior">Comportamento ilegal</string>
<string name="unknown">Desconhecido</string>
<string name="relay_icon">Ícone do relay</string>
<string name="unknown_author">Autor desconhecido</string>
<string name="copy_text">Copiar texto</string>
<string name="copy_user_pubkey">Copiar PubKey do usuário</string>
<string name="copy_note_id">Copiar ID da Nota</string>
<string name="broadcast">Transmitir</string>
<string name="block_hide_user">Bloquear e ocultar usuário</string>
<string name="report_spam_scam">Denunciar spam/fraude</string>
<string name="report_impersonation">Denunciar representação</string>
<string name="report_explicit_content">Denunciar conteúdo explícito</string>
<string name="report_illegal_behaviour">Denunciar comportamento ilegal</string>
<string name="login_with_a_private_key_to_be_able_to_reply">Faça login com uma chave privada para poder responder</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Faça login com uma chave privada para poder impulsionar postagens</string>
<string name="login_with_a_private_key_to_like_posts">Faça login com uma chave privada para curtir postagens</string>
<string name="no_zap_amount_setup_long_press_to_change">Nenhuma configuração de quantidade de Zap. Pressione e segure para alterar</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Faça login com uma chave privada para poder enviar Zaps</string>
<string name="zaps">Zaps</string>
<string name="view_count">Contagem de visualizações</string>
<string name="boost">Impulsionar</string>
<string name="quote">Citar</string>
<string name="new_amount_in_sats">Novo Valor em Sats</string>
<string name="add">Adicionar</string>
<string name="replying_to">"respondendo para "</string>
<string name="and">" e "</string>
<string name="in_channel">"no canal "</string>
<string name="profile_banner">Banner do perfil</string>
<string name="following">" Seguindo"</string>
<string name="followers">" Seguidores"</string>
<string name="profile">Perfil</string>
<string name="security_filters">Filtros de segurança</string>
<string name="log_out">Sair</string>
<string name="show_more">Mostar mais</string>
<string name="lightning_invoice">Fatura Lightning</string>
<string name="pay">Pagar</string>
<string name="lightning_tips">Doação Lightning</string>
<string name="note_to_receiver">Nota ao Destinatário</string>
<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>
<string name="can_t_find_out_the_content_type">Não é possível descobrir o tipo de conteúdo</string>
<string name="can_t_insert_the_new_content">Não é possível inserir o novo conteúdo</string>
<string name="can_t_open_the_content_output_stream">Não é possível abrir o fluxo de saída de conteúdo</string>
<string name="can_t_open_the_image_input_stream">Não é possível abrir o fluxo de entrada de imagem</string>
<string name="there_must_be_an_uploaded_image_url_in_the_response">Deve haver um URL de imagem na resposta</string>
<string name="channel_name">Nome do Canal</string>
<string name="my_awesome_group">Meu grupo</string>
<string name="picture_url">Url da foto</string>
<string name="description">Descrição</string>
<string name="about_us">"Sobre nós.. "</string>
<string name="what_s_on_your_mind">O que você está pensando?</string>
<string name="post">Salvar</string>
<string name="save">Salvar</string>
<string name="create">Criar</string>
<string name="cancel">Cancelar</string>
<string name="failed_to_upload_the_image">Falha ao enviar imagem</string>
<string name="relay_address">Endereço do Relay</string>
<string name="posts">Posts</string>
<string name="errors">Erros</string>
<string name="home_feed">Feed principal</string>
<string name="private_message_feed">Feed de mensagens privadas</string>
<string name="public_chat_feed">Feed Chat Público</string>
<string name="global_feed">Feed Global</string>
<string name="add_a_relay">Adicionar um Relay</string>
<string name="display_name">Nome de Exibição</string>
<string name="my_display_name">Meu nome de exibição</string>
<string name="username">Usuário</string>
<string name="my_username">Meu usuário</string>
<string name="about_me">Sobre mim</string>
<string name="avatar_url">URL da foto de perfil</string>
<string name="banner_url">URL do Banner</string>
<string name="website_url">URL do Site</string>
<string name="ln_address">Endereço LN</string>
<string name="ln_url_outdated">LN URL (desatualizado)</string>
<string name="image_saved_to_the_gallery">Imagem salva para a galeria</string>
<string name="failed_to_save_the_image">Falha ao salvar a imagem</string>
<string name="upload_image">Enviar Imagem</string>
<string name="uploading">Enviando...</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Usuário não tem um endereço lightning configurado para receber sats</string>
<string name="reply_here">"responda aqui.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copia o ID do canal (note) para compartilhar</string>
<string name="copy_channel_id_note_to_the_clipboard">Copiar ID do canal (Note)</string>
<string name="edits_the_channel_metadata">Editar os dados do canal</string>
<string name="join">Entrar</string>
<string name="known">Conhecidos</string>
<string name="new_requests">Novas Solicitações</string>
<string name="blocked_users">Usuários</string>
<string name="new_threads">Novos Tópicos</string>
<string name="conversations">Conversas</string>
<string name="notes">Notas</string>
<string name="replies">Respostas</string>
<string name="follows">Seguindo</string>
<string name="reports">Denúncias</string>
<string name="more_options">Mais opções</string>
<string name="relays">" Relays"</string>
<string name="website">Site</string>
<string name="lightning_address">Endereço Lightning</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">Copiar a chave Nsec (sua senha) para backup</string>
<string name="copy_private_key_to_the_clipboard">Copiar Chave Privada</string>
<string name="copies_the_public_key_to_the_clipboard_for_sharing">Copia a chave public para ser compartilhada</string>
<string name="copy_public_key_npub_to_the_clipboard">Copiar chave pública (NPub)</string>
<string name="send_a_direct_message">Enviar uma mensagem direta</string>
<string name="edits_the_user_s_metadata">Muda os dados do usuário</string>
<string name="follow">Seguir</string>
<string name="unblock">Desbloquear</string>
<string name="copy_user_id">Copiar ID do usuário</string>
<string name="unblock_user">Desbloquear Usuário</string>
<string name="npub_hex_username">"npub, hex, usuário "</string>
<string name="clear">Limpar</string>
<string name="app_logo">Logo do aplicativo</string>
<string name="nsec_npub_hex_private_key">nsec / npub / hex chave privada</string>
<string name="show_password">Mostrar senha</string>
<string name="hide_password">Esconder senha</string>
<string name="invalid_key">Chave inválida</string>
<string name="i_accept_the">"Eu aceito os "</string>
<string name="terms_of_use">termos de uso</string>
<string name="acceptance_of_terms_is_required">É necessário aceitar os termos de uso</string>
<string name="key_is_required">Chave é obrigatória</string>
<string name="login">Entrar</string>
<string name="generate_a_new_key">Gerar uma nova chave</string>
<string name="loading_feed">Carregando feed</string>
<string name="error_loading_replies">Erro ao carregar respostas</string>
<string name="try_again">Tente novamente</string>
<string name="feed_is_empty">Feed está vazio</string>
<string name="refresh">Atualizar</string>
<string name="created">criado</string>
<string name="with_description_of">com descrição de</string>
<string name="and_picture">e foto</string>
<string name="changed_chat_name_to">mudou nome do canal para</string>
<string name="description_to">descrição para</string>
<string name="and_picture_to">e foto para</string>
<string name="leave">Sair</string>
<string name="unfollow">Remover seguidor</string>
<string name="channel_created">Canal criado</string>
<string name="channel_information_changed_to">Informação do canal mudou para</string>
<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="never">nunca</string>
<string name="now">agora</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
<string name="nudity">Nudez</string>
<string name="profanity_hateful_speech">Palavrões / Discurso de ódio</string>
<string name="report_hateful_speech">Denunciar discurso de ódio</string>
<string name="report_nudity_porn">Denunciar nudez / pornografia</string>
<string name="others">outros</string>
</resources>

View File

@@ -1,4 +1,180 @@
<resources>
<string name="app_name_release">Amethyst</string>
<string name="app_name_debug">Amethyst Debug</string>
<string name="app_name_release" translatable="false">Amethyst</string>
<string name="app_name_debug" translatable="false">Amethyst Debug</string>
<string name="point_to_the_qr_code">Point to the QR Code</string>
<string name="show_qr">Show QR</string>
<string name="profile_image">Profile Image</string>
<string name="scan_qr">Scan QR</string>
<string name="show_anyway">Show Anyway</string>
<string name="post_was_flagged_as_inappropriate_by">Post was flagged as inappropriate by</string>
<string name="post_not_found">post not found</string>
<string name="channel_image">Channel Image</string>
<string name="referenced_event_not_found">Referenced event not found</string>
<string name="could_not_decrypt_the_message">Could Not decrypt the message</string>
<string name="group_picture">Group Picture</string>
<string name="explicit_content">Explicit Content</string>
<string name="spam">Spam</string>
<string name="impersonation">Impersonation</string>
<string name="illegal_behavior">Illegal Behavior</string>
<string name="unknown">Unknown</string>
<string name="relay_icon">Relay Icon</string>
<string name="unknown_author">Unknown Author</string>
<string name="copy_text">Copy Text</string>
<string name="copy_user_pubkey">Copy User PubKey</string>
<string name="copy_note_id">Copy Note ID</string>
<string name="broadcast">Broadcast</string>
<string name="block_hide_user"><![CDATA[Block & Hide User]]></string>
<string name="report_spam_scam">Report Spam / Scam</string>
<string name="report_impersonation">Report Impersonation</string>
<string name="report_explicit_content">Report Explicit Content</string>
<string name="report_illegal_behaviour">Report Illegal Behaviour</string>
<string name="login_with_a_private_key_to_be_able_to_reply">Login with a Private key to be able to reply</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Login with a Private key to be able to boost posts</string>
<string name="login_with_a_private_key_to_like_posts">Login with a Private key to like Posts</string>
<string name="no_zap_amount_setup_long_press_to_change">No Zap Amount Setup. Long Press to change</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Login with a Private key to be able to send Zaps</string>
<string name="zaps">Zaps</string>
<string name="view_count">View count</string>
<string name="boost">Boost</string>
<string name="quote">Quote</string>
<string name="new_amount_in_sats">New Amount in Sats</string>
<string name="add">Add</string>
<string name="replying_to">"replying to "</string>
<string name="and">" and "</string>
<string name="in_channel">"in channel "</string>
<string name="profile_banner">Profile Banner</string>
<string name="following">" Following"</string>
<string name="followers">" Followers"</string>
<string name="profile">Profile</string>
<string name="security_filters">Security Filters</string>
<string name="log_out">Log out</string>
<string name="show_more">Show More</string>
<string name="lightning_invoice">Lightning Invoice</string>
<string name="pay">Pay</string>
<string name="lightning_tips">Lightning Tips</string>
<string name="note_to_receiver">Note to Receiver</string>
<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>
<string name="can_t_find_out_the_content_type">Can\'t find out the content type</string>
<string name="can_t_insert_the_new_content">Can\'t insert the new content</string>
<string name="can_t_open_the_content_output_stream">Can\'t open the content output stream</string>
<string name="can_t_open_the_image_input_stream">Can\'t open the image input stream</string>
<string name="there_must_be_an_uploaded_image_url_in_the_response">There must be an uploaded image URL in the response</string>
<string name="channel_name">Channel Name</string>
<string name="my_awesome_group">My Awesome Group</string>
<string name="picture_url">Picture Url</string>
<string name="description">Description</string>
<string name="about_us">"About us.. "</string>
<string name="what_s_on_your_mind">What\'s on your mind?</string>
<string name="post">Post</string>
<string name="save">Save</string>
<string name="create">Create</string>
<string name="cancel">Cancel</string>
<string name="failed_to_upload_the_image">Failed to upload the image</string>
<string name="relay_address">Relay Address</string>
<string name="posts">Posts</string>
<string name="errors">Errors</string>
<string name="home_feed">Home Feed</string>
<string name="private_message_feed">Private Message Feed</string>
<string name="public_chat_feed">Public Chat Feed</string>
<string name="global_feed">Global Feed</string>
<string name="add_a_relay">Add a Relay</string>
<string name="display_name">Display Name</string>
<string name="my_display_name">My display name</string>
<string name="username">Username</string>
<string name="my_username">My username</string>
<string name="about_me">About me</string>
<string name="avatar_url">Avatar URL</string>
<string name="banner_url">Banner URL</string>
<string name="website_url">Website URL</string>
<string name="ln_address">LN Address</string>
<string name="ln_url_outdated">LN URL (outdated)</string>
<string name="image_saved_to_the_gallery">Image saved to the gallery</string>
<string name="failed_to_save_the_image">Failed to save the image</string>
<string name="upload_image">Upload Image</string>
<string name="uploading">Uploading…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">User does not have a lightning address setup to receive sats</string>
<string name="reply_here">"reply here.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copies the Note ID to the clipboard for sharing</string>
<string name="copy_channel_id_note_to_the_clipboard">Copy Channel ID (Note) to the Clipboard</string>
<string name="edits_the_channel_metadata">Edits the Channel Metadata</string>
<string name="join">Join</string>
<string name="known">Known</string>
<string name="new_requests">New Requests</string>
<string name="blocked_users">Blocked Users</string>
<string name="new_threads">New Threads</string>
<string name="conversations">Conversations</string>
<string name="notes">Notes</string>
<string name="replies">Replies</string>
<string name="follows">"Follows"</string>
<string name="reports">"Reports"</string>
<string name="more_options">More Options</string>
<string name="relays">" Relays"</string>
<string name="website">Website</string>
<string name="lightning_address">Lightning Address</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">Copies the Nsec ID (your password) to the clipboard for backup</string>
<string name="copy_private_key_to_the_clipboard">Copy Private Key to the Clipboard</string>
<string name="copies_the_public_key_to_the_clipboard_for_sharing">Copies the public key to the clipboard for sharing</string>
<string name="copy_public_key_npub_to_the_clipboard">Copy Public Key (NPub) to the Clipboard</string>
<string name="send_a_direct_message">Send a Direct Message</string>
<string name="edits_the_user_s_metadata">Edits the User\'s Metadata</string>
<string name="follow">Follow</string>
<string name="unblock">Unblock</string>
<string name="copy_user_id">Copy User ID</string>
<string name="unblock_user">Unblock User</string>
<string name="npub_hex_username">"npub, hex, username "</string>
<string name="clear">Clear</string>
<string name="app_logo">App Logo</string>
<string name="nsec_npub_hex_private_key">nsec / npub / hex private key</string>
<string name="show_password">Show Password</string>
<string name="hide_password">Hide Password</string>
<string name="invalid_key">Invalid key</string>
<string name="i_accept_the">"I accept the "</string>
<string name="terms_of_use">terms of use</string>
<string name="acceptance_of_terms_is_required">Acceptance of terms is required</string>
<string name="key_is_required">Key is required</string>
<string name="login">Login</string>
<string name="generate_a_new_key">Generate a new key</string>
<string name="loading_feed">Loading feed</string>
<string name="error_loading_replies">"Error loading replies: "</string>
<string name="try_again">Try again</string>
<string name="feed_is_empty">Feed is empty.</string>
<string name="refresh">Refresh</string>
<string name="created">created</string>
<string name="with_description_of">with description of</string>
<string name="and_picture">and picture</string>
<string name="changed_chat_name_to">changed chat name to</string>
<string name="description_to">description to</string>
<string name="and_picture_to">and picture to</string>
<string name="leave">Leave</string>
<string name="unfollow">Unfollow</string>
<string name="channel_created">Channel created</string>
<string name="channel_information_changed_to">"Channel Information changed to"</string>
<string name="public_chat">Public Chat</string>
<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="nip_05" translatable="false">NIP-05</string>
<string name="lnurl" translatable="false">LNURL...</string>
<string name="never">never</string>
<string name="now">now</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
<string name="nudity">Nudity</string>
<string name="profanity_hateful_speech">Profanity / Hateful speech</string>
<string name="report_hateful_speech">Report Hateful speech</string>
<string name="report_nudity_porn">Report Nudity / Porn</string>
<string name="others">others</string>
</resources>