Compare commits

...

61 Commits

Author SHA1 Message Date
Vitor Pamplona
21cb82a05d v0.027.2 2023-03-17 14:40:35 -04:00
Vitor Pamplona
412c5c557a Adding the suffix after the hashtag icon 2023-03-17 14:06:00 -04:00
Vitor Pamplona
81e31319c7 Making tags case insensitive 2023-03-17 10:22:58 -04:00
Vitor Pamplona
97ec28d2c8 Additional suffix support for hashtags. 2023-03-17 10:09:08 -04:00
Vitor Pamplona
c6906c42fd Mocking Android.Log for test classes 2023-03-17 09:47:47 -04:00
Vitor Pamplona
7921db3ba9 Scaling hashtag images 2023-03-17 09:43:09 -04:00
Vitor Pamplona
b12413cbff Removing comments from NIP19 Parser 2023-03-17 09:42:54 -04:00
Vitor Pamplona
07cd1738f1 Merge remote-tracking branch 'origin/HEAD' 2023-03-17 09:30:33 -04:00
Vitor Pamplona
b8aa7e64b3 Merge pull request #284 from believethehype/main
Added Special Bitcoin Hashtag with Icon
2023-03-17 09:30:06 -04:00
Vitor Pamplona
055ed62cb7 Merge remote-tracking branch 'origin/HEAD' 2023-03-17 09:29:26 -04:00
Vitor Pamplona
578c70d272 Correctly parsing NIP19s in Text 2023-03-17 09:28:48 -04:00
Vitor Pamplona
d43f0435d0 Merge pull request #287 from ZsZolee/main
Minor changes
2023-03-17 09:23:12 -04:00
Believethehype
52978fa237 Hashtags in front of a sentence ending should nod work, too. 2023-03-17 13:17:19 +01:00
Believethehype
f266a9e964 Special Hashtag for #Nostr 2023-03-17 12:38:47 +01:00
Zoltan
01cb27e6f5 Minor changes
Minor changes in the Hungarian translation
2023-03-17 10:12:50 +01:00
Believethehype
38730ea534 fixed some linting issues 2023-03-17 08:03:47 +01:00
believethehype
a955f55fc6 Merge branch 'vitorpamplona:main' into main 2023-03-17 07:24:29 +01:00
Believethehype
a42cdaaf91 added special Bitcoin Hashtag with Icon
(S/O to tata)
2023-03-16 23:44:25 +01:00
Vitor Pamplona
0633707ec6 Disabling online search after leaving the Search screen 2023-03-16 16:33:17 -04:00
Vitor Pamplona
8768daa06f Fixing Background color of the badge presentation 2023-03-16 16:32:42 -04:00
Vitor Pamplona
fb0a7b0ae4 Fixing missing messages on chat 2023-03-16 16:31:42 -04:00
Vitor Pamplona
f8b6b8fb45 v0.27.1 2023-03-16 14:24:58 -04:00
Vitor Pamplona
888b6daa2a Support for Following Hashtags 2023-03-16 14:20:30 -04:00
Vitor Pamplona
811c373e4c v0.27.0 2023-03-15 17:07:06 -04:00
Vitor Pamplona
1812ec296e Hashtags 2023-03-15 17:02:49 -04:00
Vitor Pamplona
59cdd81aec Shows LongForm Texts on Global 2023-03-15 17:02:13 -04:00
Vitor Pamplona
9297afc02a Deleting contact lists from pausing the app 2023-03-15 15:13:23 -04:00
Vitor Pamplona
a8d8be3307 Removing unused mentions in LocalCache 2023-03-15 14:35:10 -04:00
Vitor Pamplona
53ec6c817c Fixes the deletion of private messages and messages in tags 2023-03-15 14:28:03 -04:00
Vitor Pamplona
b7c4ec8358 Shows url host in the link preview 2023-03-15 14:06:26 -04:00
Vitor Pamplona
3426ff307c Makes sure onClick and onLongClick are null when their counterparts are as well. 2023-03-15 13:58:09 -04:00
Vitor Pamplona
cddb713b2f Adds the following icon to the chat header 2023-03-15 13:52:26 -04:00
Vitor Pamplona
1871fe6177 Locally cache following Users in the Home & Conversations filter 2023-03-15 13:40:26 -04:00
Vitor Pamplona
83a8558f23 Caching followingChannels and followingUsers to speed Global filter up 2023-03-15 13:39:47 -04:00
Vitor Pamplona
dbc155454f Checks for hex keys in the tag list before showing follows 2023-03-15 13:39:06 -04:00
Vitor Pamplona
80e251acfb Avoids creating User for all awardees of a badge. 2023-03-15 13:22:11 -04:00
Vitor Pamplona
a597bd7882 Checks the mentions for a valid Hex before creating users in the cache. 2023-03-15 13:14:10 -04:00
Vitor Pamplona
a53bb53c12 Avoids breaking urls and invoices when cutting an expandable content 2023-03-15 13:13:41 -04:00
Vitor Pamplona
4fa7aa54a8 Refactoring Note's hasReports function 2023-03-15 13:12:55 -04:00
Vitor Pamplona
a9490e1299 Refactoring calls to check if a user is the signed in user. 2023-03-15 13:12:21 -04:00
Vitor Pamplona
2bf36f1cfe Implements a faster AuthorGallery for notifications 2023-03-15 11:39:30 -04:00
Vitor Pamplona
c781d5eff7 Forces image size in profile pictures to reduce calculations before resizing the downloaded image. 2023-03-15 11:21:53 -04:00
Vitor Pamplona
e8d84795d5 Reduces badge size in Profiles 2023-03-15 11:21:00 -04:00
Vitor Pamplona
b351a781a9 Removes time measuring functions from RoboHash 2023-03-15 11:20:36 -04:00
Vitor Pamplona
97d9e96e2b Uses cached functions for isFollowing calculations 2023-03-15 11:20:09 -04:00
Vitor Pamplona
625b8c3bce Makes the reporting Menu less starkey in dark theme 2023-03-15 09:11:55 -04:00
Vitor Pamplona
edd4a4cc2c Merge pull request #275 from maxmoney21m/feature/216-merge-reports
Add separate screen for report & block
2023-03-15 08:09:07 -04:00
Vitor Pamplona
b305ccad1d Merge pull request #280 from maxmoney21m/bugfix/gitignore
Remove ignored file
2023-03-15 08:08:39 -04:00
Vitor Pamplona
14ca6f1d08 Merge pull request #281 from maxmoney21m/bugfix/string-parameters
Add parameters for strings with variables
2023-03-15 08:08:05 -04:00
maxmoney21m
ad4e6d6596 Add Block button and dialog 2023-03-15 13:40:59 +08:00
maxmoney21m
e94d2b5340 Update gitignore 2023-03-15 11:29:05 +08:00
maxmoney21m
0241bf0913 Add report & block screen 2023-03-15 11:29:04 +08:00
maxmoney21m
36885eb6dc Add parameters for strings with variables 2023-03-15 11:26:51 +08:00
maxmoney21m
c1917855d0 Remove ignored file 2023-03-15 10:42:53 +08:00
Vitor Pamplona
0272073636 Merge remote-tracking branch 'origin/HEAD' 2023-03-14 22:01:54 -04:00
Vitor Pamplona
063ef1e3a9 v0.26.2 Fdroid build 2023-03-14 21:59:59 -04:00
Vitor Pamplona
4ec7af3265 Merge pull request #279 from maxmoney21m/feature/54-reproducible-build
Add .profm sorting to build.gradle
2023-03-14 21:58:51 -04:00
maxmoney21m
f09f472f26 Add .profm sorting to build.gradle 2023-03-15 09:37:57 +08:00
Vitor Pamplona
2e365b67ea Filtering duplicates from Follows list 2023-03-14 19:15:40 -04:00
Vitor Pamplona
ee753e5c95 v0.26.1 2023-03-14 19:09:30 -04:00
Vitor Pamplona
8b53d28721 Doesn't crash when a non-hex key is inserted in the contact list 2023-03-14 19:08:06 -04:00
76 changed files with 1524 additions and 478 deletions

1
.gitignore vendored
View File

@@ -68,6 +68,7 @@ render.experimental.xml
.idea/**/caches/
.idea/**/libraries/
.idea/**/shelf/
.idea/**/codeStyles
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/.name

10
.idea/misc.xml generated
View File

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

View File

@@ -32,6 +32,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [x] Online Relay Search (NIP-50)
- [x] Internationalization
- [x] Badges (NIP-58)
- [x] Hashtags
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)

View File

@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 96
versionName "0.26.0"
versionCode 101
versionName "0.27.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -181,3 +181,35 @@ dependencies {
ktlint {
disabledRules.set(["no-wildcard-imports"])
}
// https://gitlab.com/fdroid/wiki/-/wikis/HOWTO:-diff-&-fix-APKs-for-Reproducible-Builds#differing-assetsdexoptbaselineprofm-easy-to-fix
// NB: Android Studio can't find the imports; this does not affect the
// actual build since Gradle can find them just fine.
import com.android.tools.profgen.ArtProfileKt
import com.android.tools.profgen.ArtProfileSerializer
import com.android.tools.profgen.DexFile
project.afterEvaluate {
tasks.each { task ->
if (task.name.startsWith("compile") && task.name.endsWith("ReleaseArtProfile")) {
task.doLast {
outputs.files.each { file ->
if (file.name.endsWith(".profm")) {
println("Sorting ${file} ...")
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
def profile = ArtProfileKt.ArtProfile(file)
def keys = new ArrayList(profile.profileData.keySet())
def sortedData = new LinkedHashMap()
Collections.sort keys, new DexFile.Companion()
keys.each { key -> sortedData[key] = profile.profileData[key] }
new FileOutputStream(file).with {
write(version.magicBytes$profgen)
write(version.versionBytes$profgen)
version.write$profgen(it, sortedData, "")
}
}
}
}
}
}
}

View File

@@ -47,7 +47,8 @@ private object PrefKeys {
const val TRANSLATE_TO = "translateTo"
const val ZAP_AMOUNTS = "zapAmounts"
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
@@ -183,7 +184,8 @@ object LocalPreferences {
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, account.hideDeleteRequestInfo)
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName())
putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
}.apply()
@@ -230,7 +232,8 @@ object LocalPreferences {
mapOf()
}
val hideDeleteRequestInfo = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false)
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
@@ -241,7 +244,8 @@ object LocalPreferences {
languagePreferences,
translateTo,
zapAmountChoices,
hideDeleteRequestInfo,
hideDeleteRequestDialog,
hideBlockAlertDialog,
latestContactList
)
}
@@ -289,8 +293,8 @@ object LocalPreferences {
stringPrefs.forEach { userPrefs.putString(it, appPrefs.getString(it, null)) }
stringSetPrefs.forEach { userPrefs.putStringSet(it, appPrefs.getStringSet(it, null)) }
userPrefs.putBoolean(
PrefKeys.HIDE_DELETE_REQUEST_INFO,
appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false)
PrefKeys.HIDE_DELETE_REQUEST_DIALOG,
appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
)
}.apply()
}

View File

@@ -71,6 +71,7 @@ object ServiceManager {
account?.let {
LocalCache.pruneOldAndHiddenMessages(it)
LocalCache.pruneHiddenMessages(it)
LocalCache.pruneContactLists(it)
// LocalCache.pruneNonFollows(it)
}
}

View File

@@ -59,7 +59,8 @@ class Account(
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var hideDeleteRequestInfo: Boolean = false,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var backupContactList: ContactListEvent? = null
) {
var transientHiddenUsers: Set<String> = setOf()
@@ -90,10 +91,12 @@ class Account(
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
val followsTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
if (contactList != null && follows.isNotEmpty()) {
val event = ContactListEvent.create(
follows,
followsTags,
relays,
loggedIn.privKey!!
)
@@ -101,7 +104,7 @@ class Account(
Client.send(event)
LocalCache.consume(event)
} else {
val event = ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
val event = ContactListEvent.create(listOf(), listOf(), relays, loggedIn.privKey!!)
// Keep this local to avoid erasing a good contact list.
// Client.send(event)
@@ -170,7 +173,7 @@ class Account(
return LnZapRequestEvent.create(userPubKeyHex, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
}
fun report(note: Note, type: ReportEvent.ReportType) {
fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
if (!isWriteable()) return
if (note.hasReacted(userProfile(), "⚠️")) {
@@ -185,7 +188,7 @@ class Account(
}
note.event?.let {
val event = ReportEvent.create(it, type, loggedIn.privKey!!)
val event = ReportEvent.create(it, type, loggedIn.privKey!!, content = content)
Client.send(event)
LocalCache.consume(event, null)
}
@@ -245,11 +248,13 @@ class Account(
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
val event = if (contactList != null && follows.isNotEmpty()) {
val event = if (contactList != null) {
ContactListEvent.create(
follows.plus(Contact(user.pubkeyHex, null)),
followingUsers.plus(Contact(user.pubkeyHex, null)),
followingTags,
contactList.relays(),
loggedIn.privKey!!
)
@@ -257,6 +262,35 @@ class Account(
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
ContactListEvent.create(
listOf(Contact(user.pubkeyHex, null)),
followingTags,
relays,
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
}
fun follow(tag: String) {
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
val event = if (contactList != null) {
ContactListEvent.create(
followingUsers,
followingTags.plus(tag),
contactList.relays(),
loggedIn.privKey!!
)
} else {
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
ContactListEvent.create(
followingUsers,
followingTags.plus(tag),
relays,
loggedIn.privKey!!
)
@@ -270,11 +304,33 @@ class Account(
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
if (contactList != null && follows.isNotEmpty()) {
if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
val event = ContactListEvent.create(
follows.filter { it.pubKeyHex != user.pubkeyHex },
followingUsers.filter { it.pubKeyHex != user.pubkeyHex },
followingTags,
contactList.relays(),
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
}
fun unfollow(tag: String) {
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
val event = ContactListEvent.create(
followingUsers,
followingTags.filter { !it.equals(tag, ignoreCase = true) },
contactList.relays(),
loggedIn.privKey!!
)
@@ -502,7 +558,11 @@ class Account(
fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
fun followingKeySet(): Set<HexKey> {
return userProfile().latestContactList?.verifiedFollowKeySet ?: emptySet()
return userProfile().cachedFollowingKeySet() ?: emptySet()
}
fun followingTagSet(): Set<HexKey> {
return userProfile().cachedFollowingTagSet() ?: emptySet()
}
fun isAcceptable(user: User): Boolean {
@@ -553,8 +613,13 @@ class Account(
saveable.invalidateData()
}
fun setHideDeleteRequestInfo() {
hideDeleteRequestInfo = true
fun setHideDeleteRequestDialog() {
hideDeleteRequestDialog = true
saveable.invalidateData()
}
fun setHideBlockAlertDialog() {
hideBlockAlertDialog = true
saveable.invalidateData()
}

View File

@@ -27,6 +27,10 @@ class Channel(val idHex: String) {
notes[note.idHex] = note
}
fun removeNote(note: Note) {
notes.remove(note.idHex)
}
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo

View File

@@ -0,0 +1,17 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
if (tag.lowercase() == "bitcoin" || tag.lowercase() == "btc") {
return HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color(0xFFF2A900))
} else if (tag.lowercase() == "nostr") {
return HashtagIcon(R.drawable.ht_nostr, "Nostr", Color(0xFF9C59FF))
}
return null
}
class HashtagIcon(
val icon: Int,
val description: String,
val color: Color
)

View File

@@ -270,7 +270,6 @@ object LocalCache {
// Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val awardees = event.awardees().mapNotNull { checkGetOrCreateUser(it) }
val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, awardDefinition)
@@ -354,6 +353,19 @@ object LocalCache {
masterNote.removeReport(deleteNote)
}
val channel = deleteNote.channel()
channel?.removeNote(deleteNote)
if (deleteNote.event is PrivateDmEvent) {
val author = deleteNote.author
val recipient = (deleteNote.event as? PrivateDmEvent)?.recipientPubKey()?.let { checkGetOrCreateUser(it) }
if (recipient != null && author != null) {
author.removeMessage(recipient, deleteNote)
recipient.removeMessage(author, deleteNote)
}
}
notes.remove(deleteNote.idHex)
deletedAtLeastOne = true
@@ -397,7 +409,6 @@ object LocalCache {
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
@@ -526,7 +537,6 @@ object LocalCache {
return
}
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
val replyTo = event.replyTos()
.mapNotNull { checkGetOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
@@ -698,6 +708,18 @@ object LocalCache {
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
fun pruneContactLists(userAccount: Account) {
var removingContactList = 0
users.values.forEach {
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false)) {
it.latestContactList = null
removingContactList++
}
}
println("PRUNE: $removingContactList contact lists")
}
// Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData(this)

View File

@@ -239,9 +239,9 @@ open class Note(val idHex: String) {
val dayAgo = Date().time / 1000 - 24 * 60 * 60
return reports.isNotEmpty() ||
(
author?.reports?.values?.filter {
author?.reports?.values?.any {
it.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null
}?.isNotEmpty() ?: false
} ?: false
)
}

View File

@@ -189,6 +189,7 @@ class User(val pubkeyHex: String) {
}
}
@Synchronized
fun addMessage(user: User, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
@@ -197,6 +198,15 @@ class User(val pubkeyHex: String) {
}
}
@Synchronized
fun removeMessage(user: User, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
liveSet?.messages?.invalidateData()
}
}
fun addRelayBeingUsed(relay: Relay, eventTime: Long) {
val here = relaysBeingUsed[relay.url]
if (here == null) {
@@ -236,7 +246,25 @@ class User(val pubkeyHex: String) {
}
fun isFollowing(user: User): Boolean {
return (latestContactList)?.unverifiedFollowKeySet()?.toSet()?.let {
return latestContactList?.unverifiedFollowKeySet()?.toSet()?.let {
return user.pubkeyHex in it
} ?: false
}
fun isFollowingHashtag(tag: String): Boolean {
return latestContactList?.unverifiedFollowTagSet()?.toSet()?.let {
return tag in it
} ?: false
}
fun isFollowingHashtagCached(tag: String): Boolean {
return latestContactList?.verifiedFollowTagSet?.let {
return tag.lowercase() in it
} ?: false
}
fun isFollowingCached(user: User): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return user.pubkeyHex in it
} ?: false
}
@@ -253,6 +281,10 @@ class User(val pubkeyHex: String) {
return latestContactList?.verifiedFollowKeySet ?: emptySet()
}
fun cachedFollowingTagSet(): Set<HexKey> {
return latestContactList?.verifiedFollowTagSet ?: emptySet()
}
fun cachedFollowCount(): Int? {
return latestContactList?.verifiedFollowKeySet?.size
}

View File

@@ -0,0 +1,38 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.ui.text.capitalize
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
private var hashtagToWatch: String? = null
fun createLoadHashtagFilter(): TypedFilter? {
val hashToLoad = hashtagToWatch ?: return null
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
limit = 200
)
)
}
val loadHashtagChannel = requestNewChannel()
override fun updateChannelFilters() {
loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
}
fun loadHashtag(tag: String?) {
hashtagToWatch = tag
invalidateFilters()
}
}

View File

@@ -58,9 +58,28 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.followingTagSet()
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 100
)
)
}
val followAccountChannel = requestNewChannel()
override fun updateChannelFilters() {
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowTagsFilter()).ifEmpty { null }
}
}

View File

@@ -18,7 +18,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
val mySearchString = searchString
if (mySearchString == null) {
if (mySearchString.isNullOrBlank()) {
return null
}

View File

@@ -23,16 +23,26 @@ class ContactListEvent(
val verifiedFollowKeySet: Set<HexKey> by lazy {
tags.filter { it[0] == "p" }.mapNotNull {
it.getOrNull(1)?.let { unverifiedHex: String ->
decodePublicKey(unverifiedHex).toHexKey()
try {
decodePublicKey(unverifiedHex).toHexKey()
} catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
null
}
}
}.toSet()
}
val verifiedFollowTagSet: Set<String> by lazy {
unverifiedFollowTagSet().map { it.lowercase() }.toSet()
}
val verifiedFollowKeySetAndMe: Set<HexKey> by lazy {
verifiedFollowKeySet + pubKey
}
fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) }
fun unverifiedFollowTagSet() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(1) }
fun follows() = tags.filter { it[0] == "p" }.mapNotNull {
try {
@@ -43,6 +53,10 @@ class ContactListEvent(
}
}
fun followsTags() = tags.filter { it[0] == "t" }.mapNotNull {
it.getOrNull(2)
}
fun relays(): Map<String, ReadWrite>? = try {
if (content.isNotEmpty()) {
gson.fromJson(content, object : TypeToken<Map<String, ReadWrite>>() {}.type) as Map<String, ReadWrite>
@@ -57,7 +71,7 @@ class ContactListEvent(
companion object {
const val kind = 3
fun create(follows: List<Contact>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
fun create(follows: List<Contact>, followTags: List<String>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
val content = if (relayUse != null) {
gson.toJson(relayUse)
} else {
@@ -70,7 +84,10 @@ class ContactListEvent(
} else {
listOf("p", it.pubKeyHex)
}
} + followTags.map {
listOf("t", it)
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ContactListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())

View File

@@ -39,8 +39,14 @@ open class Event(
fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
override fun isTaggedHash(hashtag: String) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1).equals(hashtag, true) }
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1)
/**
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
*/

View File

@@ -24,4 +24,8 @@ interface EventInterface {
fun hasValidSignature(): Boolean
fun isTaggedUser(loggedInUser: String): Boolean
fun isTaggedHash(hashtag: String): Boolean
fun isTaggedHashes(hashtag: Set<String>): Boolean
fun firstIsTaggedHashes(hashtag: Set<String>): String?
}

View File

@@ -57,9 +57,13 @@ class ReportEvent(
companion object {
const val kind = 1984
fun create(reportedPost: EventInterface, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
fun create(
reportedPost: EventInterface,
type: ReportType,
privateKey: ByteArray,
content: String = "",
createdAt: Long = Date().time / 1000
): ReportEvent {
val reportPostTag = listOf("e", reportedPost.id(), type.name.lowercase())
val reportAuthorTag = listOf("p", reportedPost.pubKey(), type.name.lowercase())

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
import nostr.postr.Utils
import java.util.Date
@@ -29,6 +30,9 @@ class TextNoteEvent(
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
}
findHashtags(msg).forEach {
tags.add(listOf("t", it))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())

View File

@@ -1,35 +1,43 @@
package com.vitorpamplona.amethyst.service.nip19
import android.util.Log
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.bechToBytes
import java.util.regex.Pattern
object Nip19 {
enum class Type {
USER, NOTE, RELAY, ADDRESS
}
data class Return(val type: Type, val hex: String, val relay: String? = null)
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE)
data class Return(val type: Type, val hex: String, val relay: String? = null, val additionalChars: String = "")
fun uriToRoute(uri: String?): Return? {
try {
val key = uri?.removePrefix("nostr:") ?: return null
if (uri == null) return null
val bytes = key.bechToBytes()
if (key.startsWith("npub")) {
return npub(bytes)
} else if (key.startsWith("note")) {
return note(bytes)
} else if (key.startsWith("nprofile")) {
return nprofile(bytes)
} else if (key.startsWith("nevent")) {
return nevent(bytes)
} else if (key.startsWith("nrelay")) {
return nrelay(bytes)
} else if (key.startsWith("naddr")) {
return naddr(bytes)
try {
val matcher = nip19regex.matcher(uri)
matcher.find()
val uriScheme = matcher.group(1) // nostr:
val type = matcher.group(2) // npub1
val key = matcher.group(3) // bech32
val additionalChars = matcher.group(4) ?: "" // additional chars
val bytes = (type + key).bechToBytes()
val parsed = when (type.lowercase()) {
"npub1" -> npub(bytes)
"note1" -> note(bytes)
"nprofile1" -> nprofile(bytes)
"nevent1" -> nevent(bytes)
"nrelay1" -> nrelay(bytes)
"naddr1" -> naddr(bytes)
else -> null
}
return parsed?.copy(additionalChars = additionalChars)
} catch (e: Throwable) {
println("Issue trying to Decode NIP19 $uri: ${e.message}")
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
}
return null

View File

@@ -7,7 +7,8 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
@@ -24,58 +25,55 @@ fun ClickableRoute(
val userState by userBase.live().metadata.observeAsState()
val user = userState?.user ?: return
val route = "User/${nip19.hex}"
val text = user.toBestDisplayName()
ClickableText(
text = AnnotatedString("@$text "),
onClick = { navController.navigate(route) },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
CreateClickableText(user.toBestDisplayName(), nip19.additionalChars, "User/${nip19.hex}", navController)
} else if (nip19.type == Nip19.Type.ADDRESS) {
val noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
if (noteBase == null) {
Text(
"@${nip19.hex} "
"@${nip19.hex}${nip19.additionalChars} "
)
} else {
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
ClickableText(
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Note/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
}
} else if (nip19.type == Nip19.Type.NOTE) {
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
val channel = note.channel()
if (note.event is ChannelCreateEvent) {
ClickableText(
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Channel/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else if (note.channel() != null) {
ClickableText(
text = AnnotatedString("@${note.channel()?.toBestDisplayName()} "),
onClick = { navController.navigate("Channel/${note.channel()?.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController)
} else if (channel != null) {
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController)
} else {
ClickableText(
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Note/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
}
} else {
Text(
"@${nip19.hex} "
"@${nip19.hex}${nip19.additionalChars} "
)
}
}
@Composable
fun CreateClickableText(clickablePart: String, suffix: String, route: String, navController: NavController) {
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("@$clickablePart")
}
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.onBackground).toSpanStyle()
) {
append("$suffix ")
}
},
onClick = { navController.navigate(route) }
)
}

View File

@@ -26,7 +26,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
const val SHORT_TEXT_LENGTH = 350
@@ -43,15 +42,14 @@ fun ExpandableRichTextViewer(
) {
var showFullText by remember { mutableStateOf(false) }
// Cuts the text in the first space after 350
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH)
val whereToCut = if (firstSpaceAfterCut < 0) SHORT_TEXT_LENGTH else firstSpaceAfterCut
val text = if (showFullText) {
content
} else {
val (lnStart, lnEnd) = LnInvoiceUtil.locateInvoice(content)
if (lnStart < SHORT_TEXT_LENGTH && lnEnd > 0) {
content.take(lnEnd)
} else {
content.take(SHORT_TEXT_LENGTH)
}
content.take(whereToCut)
}
Box(contentAlignment = Alignment.BottomCenter) {
@@ -67,7 +65,7 @@ fun ExpandableRichTextViewer(
)
// }
if (content.length > 350 && !showFullText) {
if (content.length > whereToCut && !showFullText) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,

View File

@@ -1,13 +1,14 @@
package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import android.util.Patterns
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -17,11 +18,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
@@ -32,6 +36,7 @@ import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material.MaterialRichText
import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -41,13 +46,13 @@ import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$")
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm|mov)$")
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$", Pattern.CASE_INSENSITIVE)
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm|mov)$", Pattern.CASE_INSENSITIVE)
val noProtocolUrlValidator = Pattern.compile("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$")
val tagIndex = Pattern.compile(".*\\#\\[([0-9]+)\\].*")
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_-]+)")
val hashTagsPattern: Pattern = Pattern.compile("#([A-Za-z0-9_-]+)")
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_\\-]+)")
val hashTagsPattern: Pattern = Pattern.compile("#([a-z0-9_\\-]+)(.*)", Pattern.CASE_INSENSITIVE)
val urlPattern: Pattern = Patterns.WEB_URL
fun isValidURL(url: String?): Boolean {
@@ -144,6 +149,8 @@ fun RichTextViewer(
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
@@ -163,6 +170,8 @@ fun RichTextViewer(
ClickableUrl(word, "https://$word")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
@@ -191,19 +200,7 @@ fun isBechLink(word: String): Boolean {
@Composable
fun BechLink(word: String, navController: NavController) {
val uri = if (word.startsWith("nostr", true)) {
word
} else if (word.startsWith("@")) {
word.replaceFirst("@", "nostr:")
} else {
"nostr:$word"
}
val nip19Route = try {
Nip19.uriToRoute(uri)
} catch (e: Exception) {
null
}
val nip19Route = Nip19.uriToRoute(word)
if (nip19Route == null) {
Text(text = "$word ")
@@ -212,6 +209,47 @@ fun BechLink(word: String, navController: NavController) {
}
}
@Composable
fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) {
val hashtagMatcher = hashTagsPattern.matcher(word)
val (tag, suffix) = try {
hashtagMatcher.find()
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
Pair(null, null)
}
if (tag != null) {
val hashtagIcon = checkForHashtagWithIcon(tag)
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("#$tag")
}
},
onClick = { navController.navigate("Hashtag/$tag") }
)
if (hashtagIcon != null) {
Icon(
painter = painterResource(hashtagIcon.icon),
contentDescription = hashtagIcon.description,
tint = hashtagIcon.color,
modifier = Modifier.size(20.dp).padding(0.dp, 5.dp, 0.dp, 0.dp)
)
}
Text(text = "$suffix ")
} else {
Text(text = "$word ")
}
}
@Composable
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
val matcher = tagIndex.matcher(word)

View File

@@ -13,8 +13,6 @@ import coil.request.ImageRequest
import coil.request.Options
import okio.Buffer
import java.security.MessageDigest
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
private fun toHex(color: Color): String {
val argb = color.toArgb()
@@ -66,26 +64,20 @@ class HashImageFetcher(
private val data: Uri
) : Fetcher {
@OptIn(ExperimentalTime::class)
override suspend fun fetch(): SourceResult {
val (value, elapsed) = measureTimedValue {
val source = try {
Buffer().apply { write(svgString(data.toString()).toByteArray()) }
} finally {
}
SourceResult(
source = ImageSource(source, context),
mimeType = null,
dataSource = DataSource.MEMORY
)
val source = try {
Buffer().apply { write(svgString(data.toString()).toByteArray()) }
} finally {
}
println("Elapsed: $elapsed")
return value
return SourceResult(
source = ImageSource(source, context),
mimeType = null,
dataSource = DataSource.MEMORY
)
}
class Factory : Fetcher.Factory<Uri> {
object Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
return HashImageFetcher(options.context, data)
}
@@ -96,7 +88,7 @@ object Robohash {
return ImageRequest
.Builder(context)
.data("robohash:$message")
.fetcherFactory(HashImageFetcher.Factory())
.fetcherFactory(HashImageFetcher.Factory)
.crossfade(100)
.build()
}

View File

@@ -0,0 +1,101 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@Composable
fun TextSpinner(label: String, placeholder: String, options: List<String>, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
var optionsShowing by remember { mutableStateOf(false) }
var currentText by remember { mutableStateOf(placeholder) }
Box(
modifier = modifier
) {
OutlinedTextField(
value = currentText,
onValueChange = {},
readOnly = true,
label = { Text(label) },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
Box(
modifier = Modifier
.matchParentSize()
.clickable(
interactionSource = interactionSource,
indication = null
) {
optionsShowing = true
focusRequester.requestFocus()
}
)
}
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
currentText = options[it]
optionsShowing = false
onSelect(it)
}
}
}
}
@Composable
fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
Dialog(onDismissRequest = onDismiss) {
Surface(
border = BorderStroke(0.25.dp, Color.LightGray),
shape = RoundedCornerShape(5.dp)
) {
LazyColumn() {
itemsIndexed(options) { index, item ->
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 16.dp)
.clickable {
onSelect(index)
}
) {
Text(text = item, color = MaterialTheme.colors.onSurface)
}
if (index < options.lastIndex) {
Divider(color = Color.LightGray, thickness = 0.25.dp)
}
}
}
}
}
}

View File

@@ -34,12 +34,18 @@ fun UrlPreviewCard(
modifier = Modifier
.clickable { runCatching { uri.openUri(url) } }
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
) {
Column {
val url = URL(previewInfo.url)
// correctly treating relative images
val imageUrl = if (previewInfo.image.startsWith("/")) {
URL(URL(previewInfo.url), previewInfo.image).toString()
URL(url, previewInfo.image).toString()
} else {
previewInfo.image
}
@@ -51,12 +57,23 @@ fun UrlPreviewCard(
modifier = Modifier.fillMaxWidth()
)
Text(
text = url.host,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = previewInfo.title,
style = MaterialTheme.typography.body2,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
.padding(start = 10.dp, end = 10.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)

View File

@@ -10,20 +10,41 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object GlobalFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feed() = LocalCache.notes.values
.asSequence()
.filter {
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) &&
it.replyTo.isNullOrEmpty()
}
.filter {
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in account.followingChannels()) &&
// does not show people the user already follows
(it.author?.pubkeyHex !in account.followingKeySet())
}
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.toList()
.reversed()
override fun feed(): List<Note> {
val followChannels = account.followingChannels()
val followUsers = account.followingKeySet()
val notes = LocalCache.notes.values
.asSequence()
.filter {
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) &&
it.replyTo.isNullOrEmpty()
}
.filter {
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in followChannels) &&
// does not show people the user already follows
(it.author?.pubkeyHex !in followUsers)
}
.filter { account.isAcceptable(it) }
.toList()
val longFormNotes = LocalCache.addressables.values
.asSequence()
.filter {
(it.event is LongTextNoteEvent) && it.replyTo.isNullOrEmpty()
}
.filter {
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in followChannels) &&
// does not show people the user already follows
(it.author?.pubkeyHex !in followUsers)
}
.filter { account.isAcceptable(it) }
.toList()
return (notes + longFormNotes)
.sortedBy { it.createdAt() }
.reversed()
}
}

View File

@@ -0,0 +1,39 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HashtagFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
var tag: String? = null
override fun feed(): List<Note> {
val myTag = tag ?: return emptyList()
return LocalCache.notes.values
.asSequence()
.filter {
(
it.event is TextNoteEvent ||
it.event is LongTextNoteEvent ||
it.event is ChannelMessageEvent ||
it.event is PrivateDmEvent
) &&
it.event?.isTaggedHash(myTag) == true
}
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.toList()
.reversed()
}
fun loadHashtag(account: Account, tag: String?) {
this.account = account
this.tag = tag
}
}

View File

@@ -11,13 +11,15 @@ object HomeConversationsFeedFilter : FeedFilter<Note>() {
override fun feed(): List<Note> {
val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
return LocalCache.notes.values
.filter {
(it.event is TextNoteEvent || it.event is RepostEvent) &&
it.author?.pubkeyHex in user.cachedFollowingKeySet() &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !HomeNewThreadFeedFilter.account.isHidden(it) } ?: true &&
it.author?.let { !account.isHidden(it) } ?: true &&
!it.isNewThread()
}
.sortedBy { it.createdAt() }

View File

@@ -12,11 +12,13 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
override fun feed(): List<Note> {
val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
val notes = LocalCache.notes.values
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) &&
it.author?.pubkeyHex in user.cachedFollowingKeySet() &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
it.isNewThread()
@@ -25,7 +27,7 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
val longFormNotes = LocalCache.addressables.values
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) &&
it.author?.pubkeyHex in user.cachedFollowingKeySet() &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
it.isNewThread()

View File

@@ -14,9 +14,9 @@ object UserProfileFollowsFeedFilter : FeedFilter<User>() {
}
override fun feed(): List<User> {
return user?.latestContactList?.unverifiedFollowKeySet()?.map {
LocalCache.getOrCreateUser(it)
}
return user?.latestContactList?.unverifiedFollowKeySet()?.mapNotNull {
LocalCache.checkGetOrCreateUser(it)
}?.toSet()
?.filter { account.isAcceptable(it) }
?.reversed() ?: emptyList()
}

View File

@@ -20,6 +20,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.FiltersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
@@ -94,6 +95,16 @@ fun AppNavigation(
})
}
Route.Hashtag.let { route ->
composable(route.route, route.arguments, content = {
HashtagScreen(
tag = it.arguments?.getString("id"),
accountViewModel = accountViewModel,
navController = navController
)
})
}
Route.Room.let { route ->
composable(route.route, route.arguments, content = {
ChatroomScreen(

View File

@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
@@ -138,6 +139,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
NostrSingleChannelDataSource.printCounter()
NostrSingleUserDataSource.printCounter()
NostrThreadDataSource.printCounter()
NostrHashtagDataSource.printCounter()
NostrUserProfileDataSource.printCounter()

View File

@@ -65,6 +65,12 @@ sealed class Route(
arguments = listOf(navArgument("id") { type = NavType.StringType })
)
object Hashtag : Route(
route = "Hashtag/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType })
)
object Room : Route(
route = "Room/{id}",
icon = R.drawable.ic_moments,

View File

@@ -277,7 +277,7 @@ fun ChatroomMessageCompose(
val eventContent = accountViewModel.decrypt(note)
val canPreview = note.author == accountUser ||
(note.author?.let { accountUser.isFollowing(it) } ?: true) ||
(note.author?.let { accountUser.isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (eventContent != null) {
@@ -344,7 +344,7 @@ fun ChatroomMessageCompose(
}
}
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}

View File

@@ -26,11 +26,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -114,18 +118,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
multiSetCard.zapEvents.forEach {
NoteAuthorPicture(
note = it.key,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
AuthorGallery(multiSetCard.zapEvents.keys, navController, account)
}
}
@@ -146,18 +139,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
multiSetCard.boostEvents.forEach {
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
AuthorGallery(multiSetCard.boostEvents, navController, account)
}
}
@@ -178,18 +160,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
multiSetCard.likeEvents.forEach {
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
AuthorGallery(multiSetCard.likeEvents, navController, account)
}
}
@@ -218,3 +189,54 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
}
}
}
@Composable
fun AuthorGallery(
authorNotes: Collection<Note>,
navController: NavController,
account: Account
) {
val accountState by account.userProfile().live().follows.observeAsState()
val accountUser = accountState?.user ?: return
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
authorNotes.forEach {
FastNoteAuthorPicture(
note = it,
navController = navController,
userAccount = accountUser,
size = 35.dp
)
}
}
}
}
@Composable
fun FastNoteAuthorPicture(
note: Note,
navController: NavController,
userAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier
) {
// can't be null if here
val author = note.author ?: return
val userState by author.live().metadata.observeAsState()
val user = userState?.user ?: return
val showFollowingMark = userAccount.isFollowingCached(user) || user == userAccount
UserPicture(
userHex = user.pubkeyHex,
userPicture = user.profilePicture(),
showFollowingMark = showFollowingMark,
size = size,
modifier = pictureModifier,
onClick = {
navController.navigate("User/${user.pubkeyHex}")
}
)
}

View File

@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.ui.note
import android.content.Intent
import android.graphics.Bitmap
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CutCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
@@ -19,6 +21,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
@@ -32,6 +35,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.get
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.google.accompanist.flowlayout.FlowRow
@@ -59,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.theme.Following
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -267,6 +273,15 @@ fun NoteCompose(
)
}
val firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet())
if (firstTag != null) {
ClickableText(
text = AnnotatedString(" #$firstTag"),
onClick = { navController.navigate("Hashtag/$firstTag") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
)
}
Text(
timeAgo(note.createdAt(), context = context),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
@@ -296,9 +311,9 @@ fun NoteCompose(
if (noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
val sortedMentions = noteEvent.mentions()
.map { LocalCache.getOrCreateUser(it) }
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { account.userProfile().isFollowing(it) }
.sortedBy { account.userProfile().isFollowingCached(it) }
val replyingDirectlyTo = note.replyTo?.lastOrNull()
if (replyingDirectlyTo != null && unPackReply) {
@@ -325,9 +340,9 @@ fun NoteCompose(
}
} else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions() != null)) {
val sortedMentions = noteEvent.mentions()
.map { LocalCache.getOrCreateUser(it) }
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { account.userProfile().isFollowing(it) }
.sortedBy { account.userProfile().isFollowingCached(it) }
note.channel()?.let {
ReplyInformationChannel(note.replyTo, sortedMentions, it, navController)
@@ -452,7 +467,7 @@ fun NoteCompose(
val eventContent = accountViewModel.decrypt(note)
val canPreview = note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowing(it) } ?: true) ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (eventContent != null) {
@@ -486,17 +501,20 @@ fun NoteCompose(
@Composable
fun BadgeDisplay(baseNote: Note) {
val background = MaterialTheme.colors.background
val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return
var backgroundFromImage by remember { mutableStateOf(background) }
Row(
modifier = Modifier
.padding(10.dp)
.clip(shape = CutCornerShape(20, 20, 0, 0))
.clip(shape = CutCornerShape(20, 20, 20, 20))
.border(
5.dp,
MaterialTheme.colors.primary.copy(alpha = 0.32f),
CutCornerShape(20)
)
.background(backgroundFromImage)
) {
Column {
badgeData.image()?.let {
@@ -507,7 +525,11 @@ fun BadgeDisplay(baseNote: Note) {
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
onSuccess = {
val backgroundColor = it.result.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199)
backgroundFromImage = Color(backgroundColor)
}
)
}
@@ -518,7 +540,8 @@ fun BadgeDisplay(baseNote: Note) {
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
.padding(start = 10.dp, end = 10.dp),
color = if (backgroundFromImage.luminance() > 0.5) lightColors().onBackground else darkColors().onBackground
)
}
@@ -682,7 +705,8 @@ fun NoteAuthorPicture(
robot = "authornotfound",
contentDescription = stringResource(R.string.unknown_author),
modifier = modifier
.fillMaxSize(1f)
.width(size)
.height(size)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
)
@@ -705,7 +729,6 @@ fun UserPicture(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UserPicture(
baseUser: User,
@@ -718,24 +741,52 @@ fun UserPicture(
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
val accountState by baseUserAccount.live().follows.observeAsState()
val accountUser = accountState?.user ?: return
val showFollowingMark = accountUser.isFollowingCached(user) || user == accountUser
UserPicture(
userHex = user.pubkeyHex,
userPicture = user.profilePicture(),
showFollowingMark = showFollowingMark,
size = size,
modifier = modifier,
onClick = onClick?.let { { it(user) } },
onLongClick = onLongClick?.let { { it(user) } }
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UserPicture(
userHex: String,
userPicture: String?,
showFollowingMark: Boolean,
size: Dp,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null
) {
Box(
Modifier
.width(size)
.height(size)
) {
RobohashAsyncImageProxy(
robot = user.pubkeyHex,
model = ResizeImage(user.profilePicture(), size),
robot = userHex,
model = ResizeImage(userPicture, size),
contentDescription = stringResource(id = R.string.profile_image),
modifier = modifier
.fillMaxSize(1f)
.width(size)
.height(size)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.run {
if (onClick != null && onLongClick != null) {
this.combinedClickable(onClick = { onClick(user) }, onLongClick = { onLongClick(user) })
this.combinedClickable(onClick = onClick, onLongClick = onLongClick)
} else if (onClick != null) {
this.clickable(onClick = { onClick(user) })
this.clickable(onClick = onClick)
} else {
this
}
@@ -743,10 +794,7 @@ fun UserPicture(
)
val accountState by baseUserAccount.live().follows.observeAsState()
val accountUser = accountState?.user ?: return
if (accountUser.isFollowing(user) || user == accountUser) {
if (showFollowingMark) {
Box(
Modifier
.width(size.div(3.5f))
@@ -779,12 +827,13 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
val clipboardManager = LocalClipboardManager.current
val appContext = LocalContext.current.applicationContext
val actContext = LocalContext.current
var reportDialogShowing by remember { mutableStateOf(false) }
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
if (note.author != accountViewModel.accountLiveData.value?.account?.userProfile() && !accountViewModel.accountLiveData.value?.account?.userProfile()!!.isFollowing(note.author!!)) {
if (!accountViewModel.isFollowing(note.author)) {
DropdownMenuItem(onClick = {
accountViewModel.follow(
note.author ?: return@DropdownMenuItem
@@ -824,57 +873,22 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) {
Text(stringResource(R.string.broadcast))
}
if (note.author == accountViewModel.accountLiveData.value?.account?.userProfile()) {
Divider()
Divider()
if (accountViewModel.isLoggedUser(note.author)) {
DropdownMenuItem(onClick = { accountViewModel.delete(note); onDismiss() }) {
Text(stringResource(R.string.request_deletion))
}
}
if (note.author != accountViewModel.accountLiveData.value?.account?.userProfile()) {
Divider()
DropdownMenuItem(onClick = {
note.author?.let {
accountViewModel.hide(it)
}; onDismiss()
}) {
Text(stringResource(R.string.block_hide_user))
}
Divider()
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.SPAM)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}) {
Text(stringResource(R.string.report_spam_scam))
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.PROFANITY)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}) {
Text(stringResource(R.string.report_hateful_speech))
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.IMPERSONATION)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}) {
Text(stringResource(R.string.report_impersonation))
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.NUDITY)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}) {
Text(stringResource(R.string.report_nudity_porn))
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.ILLEGAL)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}) {
Text(stringResource(R.string.report_illegal_behaviour))
} else {
DropdownMenuItem(onClick = { reportDialogShowing = true }) {
Text("Block / Report")
}
}
}
if (reportDialogShowing) {
ReportNoteDialog(note = note, accountViewModel = accountViewModel) {
reportDialogShowing = false
onDismiss()
}
}
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -16,6 +17,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.ButtonColors
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Card
import androidx.compose.material.Divider
import androidx.compose.material.Icon
@@ -24,11 +27,13 @@ import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AlternateEmail
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FormatQuote
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -40,6 +45,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
@@ -57,9 +63,11 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.theme.WarningColor
import kotlinx.coroutines.launch
fun lightenColor(color: Color, amount: Float): Color {
private fun lightenColor(color: Color, amount: Float): Color {
var argb = color.toArgb()
val hslOut = floatArrayOf(0f, 0f, 0f)
ColorUtils.colorToHSL(argb, hslOut)
@@ -71,7 +79,7 @@ fun lightenColor(color: Color, amount: Float): Color {
val externalLinkForNote = { note: Note -> "https://snort.social/e/${note.idNote()}" }
@Composable
fun VerticalDivider(color: Color) =
private fun VerticalDivider(color: Color) =
Divider(
color = color,
modifier = Modifier
@@ -88,9 +96,17 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
val scope = rememberCoroutineScope()
var showSelectTextDialog by remember { mutableStateOf(false) }
var showDeleteAlertDialog by remember { mutableStateOf(false) }
val isOwnNote = note.author == accountViewModel.userProfile()
var showBlockAlertDialog by remember { mutableStateOf(false) }
var showReportDialog by remember { mutableStateOf(false) }
val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!)
val backgroundColor = if (MaterialTheme.colors.isLight) {
MaterialTheme.colors.primary
} else {
MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background)
}
val showToast = { stringResource: Int ->
scope.launch {
Toast.makeText(
@@ -106,7 +122,7 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
Card(
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape),
shape = cardShape,
backgroundColor = MaterialTheme.colors.primary
backgroundColor = backgroundColor
) {
Column(modifier = Modifier.width(IntrinsicSize.Min)) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
@@ -134,6 +150,19 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
showToast(R.string.copied_note_id_to_clipboard)
onDismiss()
}
if (!isOwnNote) {
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.Block, stringResource(R.string.quick_action_block)) {
if (accountViewModel.hideBlockAlertDialog) {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
} else {
showBlockAlertDialog = true
}
}
}
}
Divider(
color = primaryLight,
@@ -144,7 +173,7 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
if (isOwnNote) {
NoteQuickActionItem(Icons.Default.Delete, stringResource(R.string.quick_action_delete)) {
if (accountViewModel.hideDeleteRequestInfo()) {
if (accountViewModel.hideDeleteRequestDialog) {
accountViewModel.delete(note)
onDismiss()
} else {
@@ -187,6 +216,14 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
ContextCompat.startActivity(context, shareIntent, null)
onDismiss()
}
if (!isOwnNote) {
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.Report, stringResource(R.string.quick_action_report)) {
showReportDialog = true
}
}
}
}
}
@@ -200,36 +237,24 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
}
if (showDeleteAlertDialog) {
AlertDialog(
onDismissRequest = { onDismiss() },
title = {
Text(text = stringResource(R.string.quick_action_request_deletion_alert_title))
},
text = {
Text(text = stringResource(R.string.quick_action_request_deletion_alert_body))
},
buttons = {
Row(
modifier = Modifier.padding(all = 8.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(
onClick = {
accountViewModel.setHideDeleteRequestInfo()
accountViewModel.delete(note)
onDismiss()
}
) {
Text(stringResource(R.string.quick_action_dont_show_again_button))
}
Button(
onClick = { accountViewModel.delete(note); onDismiss() }
) {
Text(stringResource(R.string.quick_action_delete_button))
}
}
}
)
DeleteAlertDialog(note, accountViewModel) {
showDeleteAlertDialog = false
onDismiss()
}
}
if (showBlockAlertDialog) {
BlockAlertDialog(note, accountViewModel) {
showBlockAlertDialog = false
onDismiss()
}
}
if (showReportDialog) {
ReportNoteDialog(note, accountViewModel) {
showReportDialog = false
onDismiss()
}
}
}
@@ -245,9 +270,99 @@ fun NoteQuickActionItem(icon: ImageVector, label: String, onClick: () -> Unit) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp).padding(bottom = 5.dp),
modifier = Modifier
.size(24.dp)
.padding(bottom = 5.dp),
tint = Color.White
)
Text(text = label, fontSize = 12.sp, color = Color.White, textAlign = TextAlign.Center)
}
}
@Composable
fun DeleteAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) =
QuickActionAlertDialog(
title = stringResource(R.string.quick_action_request_deletion_alert_title),
textContent = stringResource(R.string.quick_action_request_deletion_alert_body),
buttonIcon = Icons.Default.Delete,
buttonText = stringResource(R.string.quick_action_delete_dialog_btn),
onClickDoOnce = {
accountViewModel.delete(note)
onDismiss()
},
onClickDontShowAgain = {
accountViewModel.delete(note)
accountViewModel.dontShowDeleteRequestDialog()
onDismiss()
},
onDismiss = onDismiss
)
@Composable
private fun BlockAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) =
QuickActionAlertDialog(
title = stringResource(R.string.report_dialog_block_hide_user_btn),
textContent = stringResource(R.string.report_dialog_blocking_a_user),
buttonIcon = Icons.Default.Block,
buttonText = stringResource(R.string.quick_action_block_dialog_btn),
buttonColors = ButtonDefaults.buttonColors(
backgroundColor = WarningColor,
contentColor = Color.White
),
onClickDoOnce = {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
},
onClickDontShowAgain = {
note.author?.let { accountViewModel.hide(it) }
accountViewModel.dontShowBlockAlertDialog()
onDismiss()
},
onDismiss = onDismiss
)
@Composable
private fun QuickActionAlertDialog(
title: String,
textContent: String,
buttonIcon: ImageVector,
buttonText: String,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onClickDoOnce: () -> Unit,
onClickDontShowAgain: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(title)
},
text = {
Text(textContent)
},
buttons = {
Row(
modifier = Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = onClickDontShowAgain) {
Text(stringResource(R.string.quick_action_dont_show_again_button))
}
Button(onClick = onClickDoOnce, colors = buttonColors) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = buttonIcon,
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(buttonText)
}
}
}
}
)
}

View File

@@ -30,7 +30,7 @@ fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, account: Accou
@Composable
fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Account, prefix: String = "", onUserTagClick: (User) -> Unit) {
val mentions = dupMentions?.toSet()?.sortedBy { !account.userProfile().isFollowing(it) }
val mentions = dupMentions?.toSet()?.sortedBy { !account.userProfile().isFollowingCached(it) }
var expanded by remember { mutableStateOf((mentions?.size ?: 0) <= 2) }
FlowRow() {

View File

@@ -72,7 +72,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
ShowUserButton {
account.showUser(baseUser.pubkeyHex)
}
} else if (userFollows.isFollowing(baseUser)) {
} else if (userFollows.isFollowingCached(baseUser)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
} else {
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }

View File

@@ -117,7 +117,7 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
ShowUserButton {
account.showUser(baseAuthor.pubkeyHex)
}
} else if (userFollows.isFollowing(baseAuthor)) {
} else if (userFollows.isFollowingCached(baseAuthor)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseAuthor) } }
} else {
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(baseAuthor) } }

View File

@@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
@@ -33,6 +34,7 @@ class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter)
class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter)
class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter)
class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter)
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)
class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter)
class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter)

View File

@@ -328,7 +328,7 @@ fun NoteMaster(
val eventContent = note.event?.content()
val canPreview = note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowing(it) } ?: true) ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (eventContent != null) {

View File

@@ -73,8 +73,8 @@ class AccountViewModel(private val account: Account) : ViewModel() {
)
}
fun report(note: Note, type: ReportEvent.ReportType) {
account.report(note, type)
fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
account.report(note, type, content)
}
fun report(user: User, type: ReportEvent.ReportType) {
@@ -125,15 +125,26 @@ class AccountViewModel(private val account: Account) : ViewModel() {
account.unfollow(user)
}
fun isFollowing(user: User): Boolean {
return account.userProfile().isFollowing(user)
fun isLoggedUser(user: User?): Boolean {
return account.userProfile() == user
}
fun hideDeleteRequestInfo(): Boolean {
return account.hideDeleteRequestInfo
fun isFollowing(user: User?): Boolean {
if (user == null) return false
return account.userProfile().isFollowingCached(user)
}
fun setHideDeleteRequestInfo() {
account.setHideDeleteRequestInfo()
val hideDeleteRequestDialog: Boolean
get() = account.hideDeleteRequestDialog
fun dontShowDeleteRequestDialog() {
account.setHideDeleteRequestDialog()
}
val hideBlockAlertDialog: Boolean
get() = account.hideBlockAlertDialog
fun dontShowBlockAlertDialog() {
account.setHideBlockAlertDialog()
}
}

View File

@@ -12,8 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
@@ -35,7 +33,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -56,10 +53,9 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.ChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.NostrChatRoomFeedViewModel
@@ -108,7 +104,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
Column(Modifier.fillMaxHeight()) {
NostrChatroomDataSource.withUser?.let {
ChatroomHeader(it, navController = navController)
ChatroomHeader(it, account.userProfile(), navController = navController)
}
Column(
@@ -210,7 +206,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
}
@Composable
fun ChatroomHeader(baseUser: User, navController: NavController) {
fun ChatroomHeader(baseUser: User, accountUser: User, navController: NavController) {
Column(
modifier = Modifier.clickable(
onClick = { navController.navigate("User/${baseUser.pubkeyHex}") }
@@ -218,17 +214,10 @@ fun ChatroomHeader(baseUser: User, navController: NavController) {
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val authorState by baseUser.live().metadata.observeAsState()
val author = authorState?.user!!
RobohashAsyncImageProxy(
robot = author.pubkeyHex,
model = ResizeImage(author.profilePicture(), 35.dp),
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(35.dp)
.height(35.dp)
.clip(shape = CircleShape)
UserPicture(
baseUser = baseUser,
baseUserAccount = accountUser,
size = 35.dp
)
Column(modifier = Modifier.padding(start = 10.dp)) {

View File

@@ -0,0 +1,125 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val lifeCycleOwner = LocalLifecycleOwner.current
if (tag != null) {
val feedViewModel: NostrHashtagFeedViewModel = viewModel()
LaunchedEffect(tag) {
HashtagFeedFilter.loadHashtag(account, tag)
NostrHashtagDataSource.loadHashtag(tag)
feedViewModel.invalidateData()
}
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Hashtag Start")
HashtagFeedFilter.loadHashtag(account, tag)
NostrHashtagDataSource.loadHashtag(tag)
NostrHashtagDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Hashtag Stop")
HashtagFeedFilter.loadHashtag(account, null)
NostrHashtagDataSource.loadHashtag(null)
NostrHashtagDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
HashtagHeader(tag, account)
FeedView(feedViewModel, accountViewModel, navController, null)
}
}
}
}
@Composable
fun HashtagHeader(tag: String, account: Account) {
val userState by account.userProfile().live().follows.observeAsState()
val userFollows = userState?.user ?: return
val coroutineScope = rememberCoroutineScope()
Column() {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
modifier = Modifier
.padding(start = 10.dp)
.weight(1f)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(
"#$tag",
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
if (userFollows.isFollowingHashtagCached(tag)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(tag) } }
} else {
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(tag) } }
}
}
}
}
}
Divider(
modifier = Modifier.padding(start = 12.dp, end = 12.dp),
thickness = 0.25.dp
)
}
}

View File

@@ -373,7 +373,7 @@ private fun ProfileHeader(
ShowUserButton {
account.showUser(baseUser.pubkeyHex)
}
} else if (accountUser.isFollowing(baseUser)) {
} else if (accountUser.isFollowingCached(baseUser)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
} else {
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }
@@ -457,7 +457,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, navController:
val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull()
if (baseBadgeDefinition != null) {
BadgeThumb(baseBadgeDefinition, navController, 50.dp)
BadgeThumb(baseBadgeDefinition, navController, 35.dp)
}
}
}
@@ -585,7 +585,8 @@ fun BadgeThumb(
robot = "authornotfound",
contentDescription = stringResource(R.string.unknown_author),
modifier = pictureModifier
.fillMaxSize(1f)
.width(size)
.height(size)
.background(MaterialTheme.colors.background)
)
} else {
@@ -594,7 +595,8 @@ fun BadgeThumb(
model = image,
contentDescription = stringResource(id = R.string.profile_image),
modifier = pictureModifier
.fillMaxSize(1f)
.width(size)
.height(size)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.run {

View File

@@ -0,0 +1,172 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Report
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.theme.WarningColor
@Composable
fun ReportNoteDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) {
val reportTypes = listOf(
Pair(ReportEvent.ReportType.SPAM, stringResource(R.string.report_dialog_spam)),
Pair(ReportEvent.ReportType.PROFANITY, stringResource(R.string.report_dialog_profanity)),
Pair(ReportEvent.ReportType.IMPERSONATION, stringResource(R.string.report_dialog_impersonation)),
Pair(ReportEvent.ReportType.NUDITY, stringResource(R.string.report_dialog_nudity)),
Pair(ReportEvent.ReportType.ILLEGAL, stringResource(R.string.report_dialog_illegal))
)
val reasonOptions = reportTypes.map { it.second }
var additionalReason by remember { mutableStateOf("") }
var selectedReason by remember { mutableStateOf(-1) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = "Block and Report") },
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = stringResource(R.string.back),
tint = MaterialTheme.colors.onSurface
)
}
},
backgroundColor = MaterialTheme.colors.surface,
elevation = 0.dp
)
}
) { pad ->
Column(
modifier = Modifier.padding(16.dp, pad.calculateTopPadding(), 16.dp, pad.calculateBottomPadding()),
verticalArrangement = Arrangement.SpaceAround
) {
SpacerH16()
SectionHeader(text = "Block")
SpacerH16()
Text(
text = stringResource(R.string.report_dialog_blocking_a_user)
)
SpacerH16()
ActionButton(
text = stringResource(R.string.report_dialog_block_hide_user_btn),
icon = Icons.Default.Block,
onClick = {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}
)
SpacerH16()
Divider(color = MaterialTheme.colors.onSurface, thickness = 0.25.dp)
SpacerH16()
SectionHeader(text = stringResource(R.string.report_dialog_report_btn))
SpacerH16()
Text(stringResource(R.string.report_dialog_reminder_public))
SpacerH16()
TextSpinner(
label = stringResource(R.string.report_dialog_select_reason_label),
placeholder = stringResource(R.string.report_dialog_select_reason_placeholder),
options = reasonOptions,
onSelect = {
selectedReason = it
},
modifier = Modifier.fillMaxWidth()
)
SpacerH16()
OutlinedTextField(
value = additionalReason,
onValueChange = { additionalReason = it },
placeholder = { Text(text = stringResource(R.string.report_dialog_additional_reason_placeholder)) },
label = { Text(stringResource(R.string.report_dialog_additional_reason_label)) },
modifier = Modifier.fillMaxWidth()
)
SpacerH16()
ActionButton(
text = stringResource(R.string.report_dialog_post_report_btn),
icon = Icons.Default.Report,
enabled = selectedReason in 0..reportTypes.lastIndex,
onClick = {
accountViewModel.report(note, reportTypes[selectedReason].first, additionalReason)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}
)
}
}
}
}
@Composable
private fun SpacerH16() = Spacer(modifier = Modifier.height(16.dp))
@Composable
private fun SectionHeader(text: String) = Text(
text = text,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface,
fontSize = 18.sp
)
@Composable
private fun ActionButton(text: String, icon: ImageVector, enabled: Boolean = true, onClick: () -> Unit) = Button(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.buttonColors(backgroundColor = WarningColor),
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = text, color = Color.White)
}
}

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
@@ -72,6 +73,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.regex.Pattern
import kotlinx.coroutines.channels.Channel as CoroutineChannel
@Composable
@@ -95,10 +97,13 @@ fun SearchScreen(
if (event == Lifecycle.Event.ON_RESUME) {
println("Global Start")
NostrGlobalDataSource.start()
NostrSearchEventOrUserDataSource.start()
feedViewModel.refresh()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Global Stop")
NostrSearchEventOrUserDataSource.clear()
NostrSearchEventOrUserDataSource.stop()
NostrGlobalDataSource.stop()
}
}
@@ -126,7 +131,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
val searchResults = remember { mutableStateOf<List<User>>(emptyList()) }
val searchResultsNotes = remember { mutableStateOf<List<Note>>(emptyList()) }
val searchResultsChannels = remember { mutableStateOf<List<Channel>>(emptyList()) }
val hashtagResults = remember { mutableStateOf<List<String>>(emptyList()) }
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val onlineSearch = NostrSearchEventOrUserDataSource
@@ -149,6 +156,8 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
.distinctUntilChanged()
.debounce(300)
.collectLatest {
hashtagResults.value = findHashtags(it)
if (it.removePrefix("npub").removePrefix("note").length >= 4) {
onlineSearch.search(it.trim())
}
@@ -156,6 +165,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
searchResults.value = LocalCache.findUsersStartingWith(it)
searchResultsNotes.value = LocalCache.findNotesStartingWith(it).sortedBy { it.createdAt() }.reversed()
searchResultsChannels.value = LocalCache.findChannelsStartingWith(it)
// makes sure to show the top of the search
scope.launch(Dispatchers.Main) { listState.animateScrollToItem(0) }
}
}
}
@@ -236,8 +248,15 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
)
),
state = listState
) {
itemsIndexed(hashtagResults.value, key = { _, item -> "#" + item }) { _, item ->
HashtagLine(item) {
navController.navigate("Hashtag/$item")
}
}
itemsIndexed(searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item ->
UserCompose(item, accountViewModel = accountViewModel, navController = navController)
}
@@ -266,6 +285,57 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
}
}
val hashtagSearch = Pattern.compile("(?:\\s|\\A)#([A-Za-z0-9_\\-]+)")
fun findHashtags(content: String): List<String> {
val matcher = hashtagSearch.matcher(content)
val returningList = mutableSetOf<String>()
while (matcher.find()) {
try {
val tag = matcher.group(1)
if (tag != null && tag.isNotBlank()) {
returningList.add(tag)
}
} catch (e: Exception) {
}
}
return returningList.toList()
}
@Composable
fun HashtagLine(tag: String, onClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Search hashtag: #$tag",
fontWeight = FontWeight.Bold
)
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
}
@Composable
fun UserLine(
baseUser: User,

View File

@@ -12,3 +12,5 @@ val Following = Color(0xFF03DAC5)
val Nip05 = Color(0xFF01BAFF)
val FollowsFollow = Color.Yellow
val NIP05Verified = Color.Blue
val WarningColor = Color(0xFFC62828)

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -56,7 +56,6 @@
<string name="thank_you_so_much">شكرا جزيلا!</string>
<string name="amount_in_sats">المبلغ في Sats</string>
<string name="send_sats">إرسال Sats</string>
<string name="never_translate_from">"لا تترجم ابداً من "</string>
<string name="error_parsing_preview_for">خطأ في تحليل المعاينة لـ %1$s : %2$s</string>
<string name="preview_card_image_for">معاينة صورة البطاقة لـ %1$s</string>
<string name="new_channel">قناة جديدة</string>
@@ -153,12 +152,12 @@
<string name="public_chat">الدردشة العامة</string>
<string name="posts_received">تم استلام المشاركات</string>
<string name="remove">ازالة</string>
<string name="auto">تلقائي</string>
<string name="translated_from">مترجم من</string>
<string name="to">الى</string>
<string name="show_in">تظهر في</string>
<string name="first">اولا</string>
<string name="always_translate_to">ترجمة إلى</string>
<string name="translations_auto">تلقائي</string>
<string name="translations_translated_from">مترجم من</string>
<string name="translations_to">الى</string>
<string name="translations_show_in_lang_first">تظهر في %1$s اولا</string>
<string name="translations_always_translate_to_lang">ترجمة إلى %1$s</string>
<string name="translations_never_translate_from_lang">لا تترجم ابداً من %1$s</string>
<string name="never">ابداً</string>
<string name="now">الان</string>
<string name="h">س</string>

View File

@@ -71,7 +71,6 @@
<string name="thank_you_so_much">¡Muchas gracias!</string>
<string name="amount_in_sats">Cantidad en Sats</string>
<string name="send_sats">Enviar Sats</string>
<string name="never_translate_from">"Nunca traducir desde "</string>
<string name="error_parsing_preview_for">"Error al analizar la vista previa de %1$s : %2$s"</string>
<string name="preview_card_image_for">"Imagen de vista previa para %1$s"</string>
<string name="new_channel">Nuevo canal</string>
@@ -169,12 +168,12 @@
<string name="public_chat">Chat público</string>
<string name="posts_received">publicaciones recibidas</string>
<string name="remove">Eliminar</string>
<string name="auto">Auto</string>
<string name="translated_from">traducido de</string>
<string name="to">a</string>
<string name="show_in">Mostrar en</string>
<string name="first">primero</string>
<string name="always_translate_to">"Traducir siempre a "</string>
<string name="translations_auto">Auto</string>
<string name="translations_translated_from">traducido de</string>
<string name="translations_to">a</string>
<string name="translations_show_in_lang_first">Mostrar en %1$s primero</string>
<string name="translations_always_translate_to_lang">Traducir siempre a %1$s</string>
<string name="translations_never_translate_from_lang">Nunca traducir desde %1$s</string>
<string name="never">nunca</string>
<string name="now">ahora</string>
<string name="h">h</string>

View File

@@ -56,7 +56,6 @@
<string name="thank_you_so_much">Merci beaucoup!</string>
<string name="amount_in_sats">Montant en Sats</string>
<string name="send_sats">Envoyer des Sats</string>
<string name="never_translate_from">"Ne jamais traduire depuis "</string>
<string name="error_parsing_preview_for">"Erreur d\'analyse de l\'aperçu pour %1$s : %2$s"</string>
<string name="preview_card_image_for">"Aperçu de l\'image pour %1$s"</string>
<string name="new_channel">Nouveau canal</string>
@@ -154,12 +153,12 @@
<string name="public_chat">Chat public</string>
<string name="posts_received">messages reçus</string>
<string name="remove">Retirer</string>
<string name="auto">Auto</string>
<string name="translated_from">traduit de</string>
<string name="to">vers</string>
<string name="show_in">Montre en</string>
<string name="first">en premier</string>
<string name="always_translate_to">"Toujours traduire en "</string>
<string name="translations_auto">Auto</string>
<string name="translations_translated_from">traduit de</string>
<string name="translations_to">vers</string>
<string name="translations_show_in_lang_first">Montre en %1$s en premier</string>
<string name="translations_always_translate_to_lang">Toujours traduire en %1$s</string>
<string name="translations_never_translate_from_lang">Ne jamais traduire depuis %1$s</string>
<string name="never">jamais</string>
<string name="now">maintenant</string>
<string name="h">h</string>

View File

@@ -22,7 +22,7 @@
<string name="copy_user_pubkey">A felhasználó PubKulcs másolása</string>
<string name="copy_note_id">A bejegyzés azonosítójának másolása</string>
<string name="broadcast">Közvetít</string>
<string name="block_hide_user"><![CDATA[Block & Hide User]]></string>
<string name="block_hide_user"><![CDATA[Tiltás és Felhasználó elrejtése]]></string>
<string name="report_spam_scam">Spam / Csalás bejelentés</string>
<string name="report_impersonation">Megszemélyesítés bejelentés</string>
<string name="report_explicit_content">Szókimondó tartalom bejelentés</string>
@@ -56,7 +56,6 @@
<string name="thank_you_so_much">Nagyon szépen köszönöm!</string>
<string name="amount_in_sats">Összeg Sats-ban</string>
<string name="send_sats">Sats küldése</string>
<string name="never_translate_from">"Soha ne fordítsd erről "</string>
<string name="error_parsing_preview_for">"Hiba a következő előnézetében %1$s : %2$s"</string>
<string name="preview_card_image_for">"Kártyakép előnézete %1$s"</string>
<string name="new_channel">Új Csatorna</string>
@@ -66,7 +65,7 @@
<string name="description">Leírás</string>
<string name="about_us">"Rólunk.. "</string>
<string name="what_s_on_your_mind">Mire gondolsz?</string>
<string name="post">Hozzászólás</string>
<string name="post">Küldés</string>
<string name="save">Mentés</string>
<string name="create">Létrehoz</string>
<string name="cancel">Törlés</string>
@@ -86,7 +85,7 @@
<string name="my_username">Felhasználónevem</string>
<string name="about_me">Rólam</string>
<string name="avatar_url">Avatarom URL-je</string>
<string name="banner_url">Bannerem UR-jeL</string>
<string name="banner_url">Bannerem URL-je</string>
<string name="website_url">Oldalam URL-je</string>
<string name="ln_address">LN Cím</string>
<string name="ln_url_outdated">LN URL (elavult)</string>
@@ -95,7 +94,7 @@
<string name="upload_image">Kép feltöltése</string>
<string name="uploading">Feltöltés…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">A felhasználó a sat fogadáshoz nem rendelkezik LN cím beállítással</string>
<string name="reply_here">"válaszolj ide.. "</string>
<string name="reply_here">"itt tudsz válaszolni.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Megosztáshoz a bejegyzés azonosítót a vágólapra másolja</string>
<string name="copy_channel_id_note_to_the_clipboard">Bejegyzés azonosító vágólapra másolása</string>
<string name="edits_the_channel_metadata">Szerkeszti a csatorna metaadatait</string>
@@ -103,7 +102,7 @@
<string name="known">Ismert</string>
<string name="new_requests">Új kérések</string>
<string name="blocked_users">Letiltott felhasználók</string>
<string name="new_threads">Új szálak</string>
<string name="new_threads">Bejegyzések</string>
<string name="conversations">Beszélgetések</string>
<string name="notes">Bejegyzések</string>
<string name="replies">Válaszok</string>
@@ -131,7 +130,7 @@
<string name="hide_password">Jelszó elrejtése</string>
<string name="invalid_key">Érvénytelen kulcs</string>
<string name="i_accept_the">"Elfogadom a "</string>
<string name="terms_of_use">használati feltételek</string>
<string name="terms_of_use">használati feltételeket</string>
<string name="acceptance_of_terms_is_required">A feltételek elfogadása szükséges</string>
<string name="key_is_required">Kulcs szükséges</string>
<string name="login">Bejelentkezés</string>
@@ -148,18 +147,18 @@
<string name="description_to">leírását erre</string>
<string name="and_picture_to">és a képet erre</string>
<string name="leave">Kilépés</string>
<string name="unfollow">Leíratkozás</string>
<string name="unfollow">Kikövetem</string>
<string name="channel_created">Csatorna létrehozva</string>
<string name="channel_information_changed_to">"A csatornainformáció a következőre módosult"</string>
<string name="public_chat">Publikus Chat</string>
<string name="posts_received">beérkezett hozzászólások</string>
<string name="remove">Eltávolítás</string>
<string name="auto">Automatikus</string>
<string name="translated_from">fordítás erről</string>
<string name="to">erre</string>
<string name="show_in">Mutasd ebben</string>
<string name="first">első</string>
<string name="always_translate_to">Mindig fordítsa le</string>
<string name="translations_auto">Automatikus</string>
<string name="translations_translated_from">fordítás erről</string>
<string name="translations_to">erre</string>
<string name="translations_show_in_lang_first">Először %1$s nyelven</string>
<string name="translations_always_translate_to_lang">Mindig fordítsa le %1$s-ra</string>
<string name="translations_never_translate_from_lang">Soha ne fordíts %1$s-ról</string>
<string name="never">soha</string>
<string name="now">most</string>
<string name="h">ó</string>
@@ -175,7 +174,7 @@
<string name="mark_all_as_read">Összes megjelölése olvasottként</string>
<string name="account_backup_tips_md">
## Kulcs- és biztonsági mentési tippek
\n\nFiókját titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-al kezdődik. Bárki, aki hozzáfér az Ön titkos kulcsához, közzétehet tartalmat az Ön személyazonosságának használatával.
\n\nFiókját titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-el kezdődik. Bárki, aki az Ön titkos kulcsához hozzáfér, az Ön személyazonosságának használatával bármilyen tartalmat közzétehet.
\n\n- **Ne** helyezze el titkos kulcsát olyan webhelyen vagy szoftverben, amelyben nem bízik.
\n- Az Amethyst fejlesztők a titkos kulcsodat **soha** nem fogják elkérni.
\n- A fiók-helyreállításhoz, titkos kulcsáról **mindig** készítsen biztonságos biztonsági másolatot. Javasoljuk a jelszókezelő használatát.

View File

@@ -57,7 +57,6 @@
<string name="thank_you_so_much">Hartelijk bedankt!</string>
<string name="amount_in_sats">Bedrag in sats</string>
<string name="send_sats">Verstuur sats</string>
<string name="never_translate_from">"Nooit vertalen vanuit "</string>
<string name="error_parsing_preview_for">"Error parsing preview voor %1$s : %2$s"</string>
<string name="preview_card_image_for">"Voorbeeld kaartafbeelding voor %1$s"</string>
<string name="new_channel">Nieuw kanaal</string>
@@ -156,12 +155,12 @@
<string name="posts_received">berichten ontvangen</string>
<string name="remove">verwijderen</string>
<string name="sats" translatable="false">sats</string>
<string name="auto">Auto</string>
<string name="translated_from">vertaald van</string>
<string name="to">naar</string>
<string name="show_in">Laten zien in</string>
<string name="first">eerste</string>
<string name="always_translate_to">Altijd vertalen naar</string>
<string name="translations_auto">Auto</string>
<string name="translations_translated_from">vertaald van</string>
<string name="translations_to">naar</string>
<string name="translations_show_in_lang_first">Eerst laten zien in %1$s</string>
<string name="translations_always_translate_to_lang">Altijd vertalen naar %1$s</string>
<string name="translations_never_translate_from_lang">Nooit vertalen vanuit %1$s</string>
<string name="nip_05" translatable="false">NIP-05</string>
<string name="lnurl" translatable="false">LNURL...</string>
<string name="never">nooit</string>

View File

@@ -56,7 +56,6 @@
<string name="thank_you_so_much">Obrigado!</string>
<string name="amount_in_sats">Quantidade em Sats</string>
<string name="send_sats">Enviar Sats</string>
<string name="never_translate_from">"Nunca traduzir de "</string>
<string name="error_parsing_preview_for">Erro ao analisar a visualização para %1$s: %2$s</string>
<string name="preview_card_image_for">Visualize a imagem do cartão para %1$s</string>
<string name="new_channel">Novo Canal</string>
@@ -154,12 +153,12 @@
<string name="public_chat">Chat público</string>
<string name="posts_received">postagens recebidas</string>
<string name="remove">Remover</string>
<string name="auto">"Automaticamente "</string>
<string name="translated_from">"traduzido de "</string>
<string name="to">para</string>
<string name="show_in">Mostrar em</string>
<string name="first">primeiro</string>
<string name="always_translate_to">"Sempre traduzir para "</string>
<string name="translations_auto">"Automaticamente "</string>
<string name="translations_translated_from">"traduzido de "</string>
<string name="translations_to">para</string>
<string name="translations_show_in_lang_first">Mostrar em %1$s primeiro</string>
<string name="translations_always_translate_to_lang">Sempre traduzir para %1$s</string>
<string name="translations_never_translate_from_lang">Nunca traduzir de %1$s</string>
<string name="never">nunca</string>
<string name="now">agora</string>
<string name="h">h</string>

View File

@@ -55,7 +55,6 @@
<string name="thank_you_so_much">Большое спасибо!</string>
<string name="amount_in_sats">Сумма в sat</string>
<string name="send_sats">Отправить</string>
<string name="never_translate_from">"Не переводить с "</string>
<string name="error_parsing_preview_for">"Не удалось создать предпросмотр для %1$s : %2$s"</string>
<string name="preview_card_image_for">"Предпросмотр для %1$s"</string>
<string name="new_channel">Новый канал</string>
@@ -152,12 +151,12 @@
<string name="public_chat">Публичный чат</string>
<string name="posts_received">записей получено</string>
<string name="remove">Удалить</string>
<string name="auto">Автоматически</string>
<string name="translated_from">переведено с</string>
<string name="to">на</string>
<string name="show_in">Показать сперва на</string>
<string name="first" />
<string name="always_translate_to">Всегда переводить на</string>
<string name="translations_auto">Автоматически</string>
<string name="translations_translated_from">переведено с</string>
<string name="translations_to">на</string>
<string name="translations_show_in_lang_first">Показать сперва на %1$s</string>
<string name="translations_always_translate_to_lang">Всегда переводить на %1$s</string>
<string name="translations_never_translate_from_lang">Не переводить с %1$s</string>
<string name="never">никогда</string>
<string name="now">сейчас</string>
<string name="h">ч</string>

View File

@@ -56,7 +56,6 @@
<string name="thank_you_so_much">Çok teşekkürler!</string>
<string name="amount_in_sats">Satlarda Miktar</string>
<string name="send_sats">Satlar Gönder</string>
<string name="never_translate_from">Asla çevirme</string>
<string name="new_channel">Yeni Kanal</string>
<string name="channel_name">Kanal Adı</string>
<string name="my_awesome_group">Benim Harika Grubum</string>
@@ -152,12 +151,12 @@
<string name="public_chat">Herkese Açık Chat</string>
<string name="posts_received">alınan gönderiler</string>
<string name="remove">Kaldır</string>
<string name="auto">Otomatik</string>
<string name="translated_from">şundan çevirildi</string>
<string name="to">şuna</string>
<string name="show_in">Şunda göster</string>
<string name="first">ilk</string>
<string name="always_translate_to">Her zaman şuna çevir</string>
<string name="translations_auto">Otomatik</string>
<string name="translations_translated_from">şundan çevirildi</string>
<string name="translations_to">şuna</string>
<string name="translations_show_in_lang_first">Şunda göster %1$s ilk</string>
<string name="translations_always_translate_to_lang">Her zaman şuna çevir %1$s</string>
<string name="translations_never_translate_from_lang">Asla çevirme %1$s</string>
<string name="never">asla</string>
<string name="now">şimdi</string>
<string name="h">h</string>

View File

@@ -55,7 +55,6 @@
<string name="thank_you_so_much">Дуже дякуємо!</string>
<string name="amount_in_sats">Сума в sat</string>
<string name="send_sats">Надіслати</string>
<string name="never_translate_from">"Не переводити з "</string>
<string name="error_parsing_preview_for">"Не вдалося створити перегляд для %1$s : %2$s"</string>
<string name="preview_card_image_for">"Перегляд для %1$s"</string>
<string name="new_channel">Новий канал</string>
@@ -152,12 +151,12 @@
<string name="public_chat">Публічний чат</string>
<string name="posts_received">дописів отримано</string>
<string name="remove">Видалити</string>
<string name="auto">Автоматично</string>
<string name="translated_from">перекладено з</string>
<string name="to">на</string>
<string name="show_in">Показати спершу на</string>
<string name="first" />
<string name="always_translate_to">Завжди перекладати на</string>
<string name="translations_auto">Автоматично</string>
<string name="translations_translated_from">перекладено з</string>
<string name="translations_to">на</string>
<string name="translations_show_in_lang_first">Показати спершу на %1$s</string>
<string name="translations_always_translate_to_lang">Завжди перекладати на %1$s</string>
<string name="translations_never_translate_from_lang">Не переводити з %1$s</string>
<string name="never">ніколи</string>
<string name="now">зараз</string>
<string name="h">год</string>

View File

@@ -55,7 +55,6 @@
<string name="thank_you_so_much">非常感謝!</string>
<string name="amount_in_sats">聰數量</string>
<string name="send_sats">發送聰</string>
<string name="never_translate_from">"不再翻譯"</string>
<string name="error_parsing_preview_for">"解析 %1$s 預覽出錯:%2$s"</string>
<string name="preview_card_image_for">"預覽 %1$s 卡片圖片"</string>
<string name="new_channel">新頻道</string>
@@ -150,16 +149,15 @@
<string name="unfollow">取關</string>
<string name="channel_created">頻道已創建</string>
<string name="channel_information_changed_to">頻道信息已更改為</string>
<string name="public_chat">公共聊天</string>
<string name="posts_received">收到文章</string>
<string name="remove">移除</string>
<string name="auto">自動</string>
<string name="translated_from"></string>
<string name="to">更改為</string>
<string name="show_in">先顯示</string>
<string name="first">在前</string>
<string name="always_translate_to">總是翻譯為</string>
<string name="translations_auto">自動</string>
<string name="translations_translated_from"></string>
<string name="translations_to">更改為</string>
<string name="translations_show_in_lang_first">先顯示 %1$s 在前</string>
<string name="translations_always_translate_to_lang">總是翻譯為 %1$s</string>
<string name="translations_never_translate_from_lang">不再翻譯 %1$s</string>
<string name="never">從不</string>
<string name="now">現在</string>
<string name="h">小時</string>

View File

@@ -55,7 +55,6 @@
<string name="thank_you_so_much">非常感謝!</string>
<string name="amount_in_sats">數量 (sats)</string>
<string name="send_sats">發送 sats</string>
<string name="never_translate_from">"不再翻譯"</string>
<string name="error_parsing_preview_for">"解析 %1$s 預覽出錯:%2$s"</string>
<string name="preview_card_image_for">"預覽 %1$s 卡片圖片"</string>
<string name="new_channel">新頻道</string>
@@ -150,16 +149,15 @@
<string name="unfollow">取關</string>
<string name="channel_created">頻道已創建</string>
<string name="channel_information_changed_to">頻道信息已更改為</string>
<string name="public_chat">公共聊天</string>
<string name="posts_received">收到文章</string>
<string name="remove">移除</string>
<string name="auto">自動</string>
<string name="translated_from"></string>
<string name="to">更改為</string>
<string name="show_in">顯示</string>
<string name="first">在前</string>
<string name="always_translate_to">總是翻譯為</string>
<string name="translations_auto">自動</string>
<string name="translations_translated_from"></string>
<string name="translations_to">更改為</string>
<string name="translations_show_in_lang_first">顯示 %1$s 在前</string>
<string name="translations_always_translate_to_lang">總是翻譯為 %1$s</string>
<string name="translations_never_translate_from_lang">不再翻譯 %1$s</string>
<string name="never">從不</string>
<string name="now">現在</string>
<string name="h">小時</string>

View File

@@ -55,7 +55,6 @@
<string name="thank_you_so_much">非常感谢!</string>
<string name="amount_in_sats">聪数量</string>
<string name="send_sats">发送聪</string>
<string name="never_translate_from">"不再翻译"</string>
<string name="error_parsing_preview_for">"解析 %1$s 预览出错:%2$s"</string>
<string name="preview_card_image_for">"预览 %1$s 卡片图片"</string>
<string name="new_channel">新频道</string>
@@ -153,12 +152,12 @@
<string name="public_chat">公共聊天</string>
<string name="posts_received">收到文章</string>
<string name="remove">移除</string>
<string name="auto">自动</string>
<string name="translated_from"></string>
<string name="to">更改为</string>
<string name="show_in">先显示</string>
<string name="first">在前</string>
<string name="always_translate_to">总是翻译为</string>
<string name="translations_auto">自动</string>
<string name="translations_translated_from"></string>
<string name="translations_to">更改为</string>
<string name="translations_show_in_lang_first">先显示 %1$s 在前</string>
<string name="translations_always_translate_to_lang">总是翻译为 %1$s</string>
<string name="translations_never_translate_from_lang">不再翻译 %1$s</string>
<string name="never">从不</string>
<string name="now">现在</string>
<string name="h">小时</string>

View File

@@ -58,7 +58,6 @@
<string name="thank_you_so_much">Thank you so much!</string>
<string name="amount_in_sats">Amount in Sats</string>
<string name="send_sats">Send Sats</string>
<string name="never_translate_from">"Never translate from "</string>
<string name="error_parsing_preview_for">"Error parsing preview for %1$s : %2$s"</string>
<string name="preview_card_image_for">"Preview Card Image for %1$s"</string>
<string name="new_channel">New Channel</string>
@@ -157,12 +156,12 @@
<string name="posts_received">posts received</string>
<string name="remove">Remove</string>
<string name="sats" translatable="false">sats</string>
<string name="auto">Auto</string>
<string name="translated_from">translated from</string>
<string name="to">to</string>
<string name="show_in">Show in</string>
<string name="first">first</string>
<string name="always_translate_to">Always translate to</string>
<string name="translations_auto">Auto</string>
<string name="translations_translated_from">translated from</string>
<string name="translations_to">to</string>
<string name="translations_show_in_lang_first">Show in %1$s first</string>
<string name="translations_always_translate_to_lang">Always translate to %1$s</string>
<string name="translations_never_translate_from_lang">Never translate from %1$s</string>
<string name="nip_05" translatable="false">NIP-05</string>
<string name="lnurl" translatable="false">LNURL...</string>
<string name="never">never</string>
@@ -198,6 +197,23 @@
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">Copied authors @npub to clipboard</string>
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">Copied note ID (@note1) to clipboard</string>
<string name="select_text_dialog_top">Select Text</string>
<string name="github" translatable="false">Github Gist w/ Proof</string>
<string name="telegram" translatable="false">Telegram</string>
<string name="mastodon" translatable="false">Mastodon Post ID w/ Proof</string>
<string name="twitter" translatable="false">Twitter Status w/ Proof</string>
<string name="github_proof_url_template" translatable="false">https://gist.github.com/&lt;user&gt;/&lt;gist&gt;</string>
<string name="telegram_proof_url_template" translatable="false">https://t.me/&lt;proof post&gt;</string>
<string name="mastodon_proof_url_template" translatable="false">https://&lt;server&gt;/&lt;user&gt;/&lt;proof post&gt;</string>
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/&lt;user&gt;/status/&lt;proof post&gt;</string>
<string name="private_conversation_notification">"&lt;Unable to decrypt private message&gt;\n\nYou were cited in a private/encrypted conversation between %1$s and %2$s."</string>
<string name="account_switch_add_account_dialog_title">Add New Account</string>
<string name="drawer_accounts">Accounts</string>
<string name="account_switch_select_account">Select Account</string>
<string name="account_switch_add_account_btn">Add New Account</string>
<string name="account_switch_active_account">Active account</string>
<string name="account_switch_has_private_key">Has private key</string>
<string name="account_switch_pubkey_only">Read only, no private key</string>
<string name="back">Back</string>
<string name="quick_action_select">Select</string>
<string name="quick_action_share_browser_link">Share Browser Link</string>
<string name="quick_action_share">Share</string>
@@ -209,24 +225,25 @@
<string name="quick_action_follow">Follow</string>
<string name="quick_action_request_deletion_alert_title">Request Deletion</string>
<string name="quick_action_request_deletion_alert_body">Amethyst will request that your note be deleted from the relays you are currently connected to. There is no guarantee that your note will be permanently deleted from those relays, or from other relays where it may be stored.</string>
<string name="github" translatable="false">Github Gist w/ Proof</string>
<string name="telegram" translatable="false">Telegram</string>
<string name="mastodon" translatable="false">Mastodon Post ID w/ Proof</string>
<string name="twitter" translatable="false">Twitter Status w/ Proof</string>
<string name="github_proof_url_template" translatable="false">https://gist.github.com/&lt;user&gt;/&lt;gist&gt;</string>
<string name="telegram_proof_url_template" translatable="false">https://t.me/&lt;proof post&gt;</string>
<string name="mastodon_proof_url_template" translatable="false">https://&lt;server&gt;/&lt;user&gt;/&lt;proof post&gt;</string>
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/&lt;user&gt;/status/&lt;proof post&gt;</string>
<string name="private_conversation_notification">"&lt;Unable to decrypt private message&gt;\n\nYou were cited in a private/encrypted conversation between %1$s and %2$s."</string>
<string name="quick_action_block_dialog_btn">Block</string>
<string name="quick_action_delete_dialog_btn">Delete</string>
<string name="quick_action_block">Block</string>
<string name="quick_action_report">Report</string>
<string name="quick_action_delete_button">Delete</string>
<string name="quick_action_dont_show_again_button">Don\'t show again</string>
<string name="account_switch_add_account_dialog_title">Add New Account</string>
<string name="drawer_accounts">Accounts</string>
<string name="account_switch_select_account">Select Account</string>
<string name="account_switch_add_account_btn">Add New Account</string>
<string name="account_switch_active_account">Active account</string>
<string name="account_switch_has_private_key">Has private key</string>
<string name="account_switch_pubkey_only">Read only, no private key</string>
<string name="back">Back</string>
<string name="report_dialog_spam">Spam or scams</string>
<string name="report_dialog_profanity">Profanity or hateful conduct</string>
<string name="report_dialog_impersonation">Malicious impersonation</string>
<string name="report_dialog_nudity">Nudity or graphic content</string>
<string name="report_dialog_illegal">Illegal Behavior</string>
<string name="report_dialog_blocking_a_user">Blocking a user will hide their content in your app. Your notes are still publicly viewable, including to people you block. Blocked users are listed on the Security Filters screen.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Block & Hide User]]></string>
<string name="report_dialog_report_btn">Report Abuse</string>
<string name="report_dialog_reminder_public">All reports posted will be publicly visible.</string>
<string name="report_dialog_additional_reason_placeholder">Optionally provide additional context about your report…</string>
<string name="report_dialog_additional_reason_label">Additional Context</string>
<string name="report_dialog_select_reason_label">Reason</string>
<string name="report_dialog_select_reason_placeholder">Select a reason…</string>
<string name="report_dialog_post_report_btn">Post Report</string>
</resources>

View File

@@ -111,17 +111,17 @@ fun TranslateableRichTextViewer(
val annotatedTranslationString = buildAnnotatedString {
withStyle(clickableTextStyle) {
pushStringAnnotation("langSettings", true.toString())
append(stringResource(R.string.auto))
append(stringResource(R.string.translations_auto))
}
append("-${stringResource(R.string.translated_from)} ")
append("-${stringResource(R.string.translations_translated_from)} ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", true.toString())
append(Locale(source).displayName)
}
append(" ${stringResource(R.string.to)} ")
append(" ${stringResource(R.string.translations_to)} ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", false.toString())
@@ -166,7 +166,7 @@ fun TranslateableRichTextViewer(
Spacer(modifier = Modifier.size(10.dp))
Text(stringResource(R.string.never_translate_from) + Locale(source).displayName)
Text(stringResource(R.string.translations_never_translate_from_lang, Locale(source).displayName))
}
Divider()
DropdownMenuItem(onClick = {
@@ -185,13 +185,7 @@ fun TranslateableRichTextViewer(
Spacer(modifier = Modifier.size(10.dp))
Text(
"${stringResource(R.string.show_in)} ${Locale(source).displayName} ${
stringResource(
R.string.first
)
}"
)
Text(stringResource(R.string.translations_show_in_lang_first, Locale(source).displayName))
}
DropdownMenuItem(onClick = {
accountViewModel.prefer(source, target, target)
@@ -209,13 +203,7 @@ fun TranslateableRichTextViewer(
Spacer(modifier = Modifier.size(10.dp))
Text(
"${stringResource(R.string.show_in)} ${Locale(target).displayName} ${
stringResource(
R.string.first
)
}"
)
Text(stringResource(R.string.translations_show_in_lang_first, Locale(target).displayName))
}
Divider()
@@ -239,7 +227,7 @@ fun TranslateableRichTextViewer(
Spacer(modifier = Modifier.size(10.dp))
Text("${stringResource(R.string.always_translate_to)}${lang.displayName}")
Text(stringResource(R.string.translations_always_translate_to_lang, lang.displayName))
}
}
}

View File

@@ -0,0 +1,35 @@
package android.util;
public class Log {
public static int d(String tag, String msg) {
System.out.println("DEBUG: " + tag + ": " + msg);
return 0;
}
public static int i(String tag, String msg) {
System.out.println("INFO: " + tag + ": " + msg);
return 0;
}
public static int w(String tag, String msg) {
System.out.println("WARN: " + tag + ": " + msg);
return 0;
}
public static int w(String tag, String msg, Throwable e) {
System.out.println("WARN: " + tag + ": " + msg);
e.printStackTrace();
return 0;
}
public static int e(String tag, String msg) {
System.out.println("ERROR: " + tag + ": " + msg);
return 0;
}
public static int e(String tag, String msg, Throwable e) {
System.out.println("ERROR: " + tag + ": " + msg);
e.printStackTrace();
return 0;
}
}