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