Compare commits

...
18 Commits
Author SHA1 Message Date
Vitor Pamplona e0596dc3b9 New Version for PlayStore 2023-02-28 21:24:40 -05:00
Vitor Pamplona 381db713ab 0.20.4 2023-02-28 20:13:36 -05:00
Vitor Pamplona b6f5cc9ae5 Activating the Atomic Boolean instead of Synchronized sections 2023-02-28 20:10:52 -05:00
Vitor Pamplona df43e730ca Migrating the NostrDataSource to Atomic Boolean 2023-02-28 19:52:16 -05:00
Vitor Pamplona 95423bdd94 No need for account in search 2023-02-28 19:50:45 -05:00
Vitor Pamplona a18479e721 Reducing the need to start/stop home and chatroom lists to see if performance increases 2023-02-28 18:24:28 -05:00
Vitor Pamplona f68f32f6e5 Decrypting notes since Notifications can also show Private Message Likes 2023-02-28 16:51:35 -05:00
Vitor Pamplona ddc1e9fd69 Reverts to show all replies in Notifications. 2023-02-28 15:35:41 -05:00
Vitor Pamplona 3f99b602a7 Fixing spacing of the multicard notification 2023-02-28 15:27:28 -05:00
Vitor Pamplona b09577f816 Updating reports to the latest spec 2023-02-28 15:24:23 -05:00
Vitor Pamplona 3780ed001a Refining template texts 2023-02-28 11:37:39 -05:00
Vitor PamplonaandGitHub f8e49df126 Update issue templates 2023-02-28 11:34:12 -05:00
Vitor Pamplona 55e31d445e Simplifying relay set feature 2023-02-28 11:02:05 -05:00
Vitor Pamplona 5300d7bbe4 Review of Feature List 2023-02-28 11:01:24 -05:00
Vitor Pamplona 77fbb6f4f6 v0.20.3 2023-02-27 21:13:46 -05:00
Vitor Pamplona b570d80ac4 adding a delay to the search screen update on start up 2023-02-27 21:04:51 -05:00
Vitor Pamplona a7e17c810d Merging all notification reactions in one card. 2023-02-27 21:04:29 -05:00
Vitor Pamplona 256de32b7c Shorter Blank and Hidden notes. 2023-02-27 19:17:09 -05:00
32 changed files with 450 additions and 171 deletions
+32
View File
@@ -0,0 +1,32 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Video and Screenshots**
If applicable, add a video and/or screenshots to help explain your problem.
**Device (please complete the following information):**
- Phone Brand/Model [e.g. Pixel 7 Pro]:
- Android Version [e.g. 33]:
- App Version [e.g. v0.20.3]:
**Bounty (in Bitcoin sats) offered for a solution**
Incentivize developers to work on your issue. Describe clear milestones to claim payment.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE]"
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Bounty (in Bitcoin sats) offered for the implementation**
Incentivize developers to work on your feature. Describe clear milestones to claim payment.
**Additional context**
Add any other context, video, or screenshots about the feature request here.
+31 -14
View File
@@ -6,31 +6,48 @@ Amethyst brings the best social network to your Android phone. Just insert your
# Current Features
- [x] Account Management
- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15)
- [x] Home Feed
- [x] Notifications Feed
- [x] Global Feed
- [x] Reactions (like, boost, reply)
- [x] Image/Video Preview (gifs, svgs, etc)
- [x] Url Preview
- [x] Reactions (likes NIP-25, boost, reply)
- [x] Image/Video/Url/LnInvoice Previews
- [x] View Threads
- [x] Private Messages (NIP-04)
- [x] User Profiles (follow/unfollow)
- [x] User Profiles (edit/follow/unfollow - NIP-02)
- [x] Public Chats (NIP-28)
- [x] Bech Encoding support (NIP-19)
- [x] Notification Dots
- [x] Reporting and Hide User capability
- [x] Reporting and Hide User capability (NIP-56)
- [x] Automatic Translations
- [x] Profile Edit
- [x] Relay Edit (separate relay sets for home, dms, public chats, global)
- [x] Dropdown to Link Users when writing
- [x] Relay Sets (home, dms, public chats, global)
- [x] User/Note Tagging (NIP-08, NIP-10)
- [x] Lightning Tips
- [x] Zaps
- [x] Zaps (NIP-57)
- [x] URI Support (NIP-21)
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
- [x] Identity Verification (NIP-05)
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Sensitive Content (NIP-36)
- [ ] Relay Pages (NIP-11)
- [ ] Relay Authentication (NIP-42)
- [ ] Generic Tags (NIP-12)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Events with a Subject (NIP-14)
- [ ] Long-form Content (NIP-23)
- [ ] Online Relay Search (NIP-50)
- [ ] Workspaces
- [ ] Expiration Support (NIP-40)
- [ ] Internationalization
- [ ] Infinity Scroll
- [ ] Identity Verification (NIP-05)
- [ ] Account Creation / Backup Guidance
- [ ] Message Sent feedback
- [ ] Infinity Scroll
- [ ] Badges (NIP-58)
- [ ] Relay List Metadata (NIP-65)
- [ ] Signing Requests (NIP-46)
- [ ] Delegated Event Signing (NIP-26)
- [ ] Account Creation / Backup Guidance (NIP-06)
- [ ] Message Sent feedback (NIP-20)
# Development Overview
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 76
versionName "0.20.2"
versionCode 79
versionName "0.20.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -44,12 +44,10 @@ class NotificationLiveData(val cache: NotificationCache): LiveData<NotificationS
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
@@ -33,7 +33,6 @@ object ServiceManager {
NostrAccountDataSource.account = myAccount
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrGlobalDataSource.account = myAccount
// Notification Elements
NostrAccountDataSource.start()
@@ -605,11 +605,9 @@ class Account(
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {
@@ -67,12 +67,10 @@ class AntiSpamLiveData(val cache: AntiSpamFilter): LiveData<AntiSpamState>(AntiS
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
@@ -23,7 +23,6 @@ class Channel(val idHex: String) {
return info.name ?: idDisplayNote()
}
@Synchronized
fun addNote(note: Note) {
notes[note.idHex] = note
}
@@ -408,8 +408,8 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
val mentions = event.reportedAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.reportedPost.mapNotNull { checkGetOrCreateNote(it) }
val mentions = event.reportedAuthor.mapNotNull { checkGetOrCreateUser(it.key) }
val repliesTo = event.reportedPost.mapNotNull { checkGetOrCreateNote(it.key) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -735,12 +735,10 @@ class LocalCacheLiveData(val cache: LocalCache): LiveData<LocalCacheState>(Local
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
@@ -18,8 +18,10 @@ import java.util.regex.Pattern
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.events.Event
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
@@ -318,17 +320,20 @@ class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting.set(false)
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
@@ -289,7 +289,7 @@ class User(val pubkeyHex: String) {
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
return reports[loggedIn]?.firstOrNull() {
it.event is ReportEvent && (it.event as ReportEvent).reportType.contains(type)
it.event is ReportEvent && (it.event as ReportEvent).reportedAuthor.any { it.reportType == type }
} != null
}
@@ -379,11 +379,9 @@ class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
@@ -17,11 +17,14 @@ import com.vitorpamplona.amethyst.service.relays.Subscription
import com.vitorpamplona.amethyst.service.relays.hasValidSignature
import java.util.Date
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.events.ContactListEvent
import nostr.postr.events.DeletionEvent
import nostr.postr.events.Event
@@ -42,6 +45,7 @@ abstract class NostrDataSource(val debugName: String) {
}
}
private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
if (subscriptionId in subscriptions.keys) {
@@ -91,7 +95,6 @@ abstract class NostrDataSource(val debugName: String) {
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -123,10 +126,12 @@ abstract class NostrDataSource(val debugName: String) {
}
open fun start() {
println("DataSource: ${this.javaClass.simpleName} Start")
resetFilters()
}
open fun stop() {
println("DataSource: ${this.javaClass.simpleName} Stop")
subscriptions.values.forEach { channel ->
Client.close(channel.id)
channel.typedFilters = null
@@ -144,17 +149,24 @@ abstract class NostrDataSource(val debugName: String) {
subscriptions = subscriptions.minus(subscription.id)
}
var handlerWaiting = false
@Synchronized
fun invalidateFilters() {
if (handlerWaiting) return
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
fun invalidateFilters() {
if (handlerWaiting.getAndSet(true)) return
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
delay(200)
resetFiltersSuspend()
handlerWaiting = false
try {
delay(200)
resetFiltersSuspend()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
@@ -8,7 +8,6 @@ import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrGlobalDataSource: NostrDataSource("GlobalFeed") {
lateinit var account: Account
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
@@ -5,6 +5,8 @@ import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType)
// NIP 56 event.
class ReportEvent (
id: ByteArray,
@@ -15,14 +17,37 @@ class ReportEvent (
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val reportType: List<ReportType>
@Transient val reportedPost: List<String>
@Transient val reportedAuthor: List<String>
@Transient val reportedPost: List<ReportedKey>
@Transient val reportedAuthor: List<ReportedKey>
init {
reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }
reportedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
reportedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
// Works with old and new structures for report.
var reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull()
if (reportType == null) {
reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull()
}
if (reportType == null) {
reportType = ReportType.SPAM
}
reportedPost = tags
.filter { it.firstOrNull() == "e" && it.getOrNull(1) != null }
.map {
ReportedKey(
it[1],
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: reportType
)
}
reportedAuthor = tags
.filter { it.firstOrNull() == "p" && it.getOrNull(1) != null }
.map {
ReportedKey(
it[1],
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: reportType
)
}
}
companion object {
@@ -31,12 +56,11 @@ class ReportEvent (
fun create(reportedPost: Event, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
val reportTypeTag = listOf("report", type.name.toLowerCase())
val reportPostTag = listOf("e", reportedPost.id.toHex())
val reportAuthorTag = listOf("p", reportedPost.pubKey.toHex())
val reportPostTag = listOf("e", reportedPost.id.toHex(), type.name.toLowerCase())
val reportAuthorTag = listOf("p", reportedPost.pubKey.toHex(), type.name.toLowerCase())
val pubKey = Utils.pubkeyCreate(privateKey)
val tags:List<List<String>> = listOf(reportTypeTag, reportPostTag, reportAuthorTag)
val tags:List<List<String>> = listOf(reportPostTag, reportAuthorTag)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
@@ -45,11 +69,10 @@ class ReportEvent (
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
val reportTypeTag = listOf("report", type.name.toLowerCase())
val reportAuthorTag = listOf("p", reportedUser)
val reportAuthorTag = listOf("p", reportedUser, type.name.toLowerCase())
val pubKey = Utils.pubkeyCreate(privateKey)
val tags:List<List<String>> = listOf(reportTypeTag, reportAuthorTag)
val tags:List<List<String>> = listOf(reportAuthorTag)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
@@ -57,9 +80,11 @@ class ReportEvent (
}
enum class ReportType() {
EXPLICIT,
EXPLICIT, // Not used anymore.
ILLEGAL,
SPAM,
IMPERSONATION
IMPERSONATION,
NUDITY,
PROFANITY,
}
}
@@ -94,7 +94,6 @@ object RelayPool: Relay.Listener {
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
}
@Synchronized
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
}
@@ -23,19 +23,6 @@ object NotificationFeedFilter: FeedFilter<Note>() {
&& it.event !is ChannelMetadataEvent
&& it.event !is LnZapRequestEvent
}
.filter {
it.event !is TextNoteEvent
||
(
it.event is TextNoteEvent
&&
(
it.replyTo?.lastOrNull()?.author == account.userProfile()
||
account.userProfile() in it.directlyCiteUsers()
)
)
}
.filter {
it.event !is ReactionEvent
||
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -29,13 +30,14 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
modifier = Modifier.padding(
start = 20.dp,
end = 20.dp,
bottom = 25.dp,
bottom = 8.dp,
top = 15.dp
),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "Referenced post not found",
text = "Post not found",
modifier = Modifier.padding(30.dp),
color = Color.Gray,
)
@@ -59,9 +61,7 @@ fun HiddenNote(reports: Set<Note>, loggedIn: User, modifier: Modifier = Modifier
Row(
modifier = Modifier.padding(
start = 20.dp,
end = 20.dp,
bottom = 25.dp,
top = 15.dp
end = 20.dp
),
verticalAlignment = Alignment.CenterVertically
) {
@@ -97,7 +97,6 @@ fun HiddenNote(reports: Set<Note>, loggedIn: User, modifier: Modifier = Modifier
}
Divider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = 0.25.dp
)
}
@@ -0,0 +1,216 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MultiSetCompose(multiSetCard: MultiSetCard, modifier: Modifier = Modifier, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by multiSetCard.note.live().metadata.observeAsState()
val note = noteState?.note
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val context = LocalContext.current.applicationContext
val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) }
if (note == null) {
BlankNote(Modifier, false)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = multiSetCard) {
isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
NotificationCache.markAsRead(routeForLastRead, multiSetCard.createdAt, context)
}
var backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
Column(
modifier = Modifier
.background(backgroundColor)
.combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
} else {
note.channel?.let {
navController.navigate("Channel/${it.idHex}")
}
}
},
onLongClick = { popupExpanded = true }
)
) {
Row(modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp)
) {
Column(Modifier.fillMaxWidth()) {
if (multiSetCard.zapEvents.isNotEmpty()) {
Row(Modifier.fillMaxWidth()) {
// Draws the like picture outside the boosted card.
Box(
modifier = Modifier
.width(55.dp)
.padding(0.dp)
) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = "Zaps",
tint = BitcoinOrange,
modifier = Modifier
.size(25.dp)
.align(Alignment.TopEnd)
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
multiSetCard.zapEvents.forEach {
NoteAuthorPicture(
note = it.key,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
}
}
if (multiSetCard.boostEvents.isNotEmpty()) {
Row(Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.width(55.dp)
.padding(end = 4.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Modifier
.size(18.dp)
.align(Alignment.TopEnd),
tint = Color.Unspecified
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
multiSetCard.boostEvents.forEach {
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
}
}
if (multiSetCard.likeEvents.isNotEmpty()) {
Row(Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.width(55.dp)
.padding(end = 5.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Modifier
.size(16.dp)
.align(Alignment.TopEnd),
tint = Color.Unspecified
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
multiSetCard.likeEvents.forEach {
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
}
}
Row(Modifier.fillMaxWidth()) {
Box(modifier = Modifier
.width(65.dp)
.padding(0.dp)) {
}
NoteCompose(
baseNote = note,
routeForLastRead = null,
modifier = Modifier.padding(top = 5.dp),
isBoostedNote = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
navController = navController
)
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
}
}
}
@@ -94,7 +94,8 @@ fun ObserveDisplayNip05Status(baseUser: User) {
user.nip05()?.let { nip05 ->
if (nip05.split("@").size == 2) {
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05.split("@")[0] != "_")
Text(
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Following
import kotlin.time.ExperimentalTime
import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.TextNoteEvent
@OptIn(ExperimentalFoundationApi::class)
@@ -315,26 +316,29 @@ fun NoteCompose(
)
}
} else if (noteEvent is ReportEvent) {
val reportType = noteEvent.reportType.map {
when (it) {
val reportType = (noteEvent.reportedPost + noteEvent.reportedAuthor).map {
when (it.reportType) {
ReportEvent.ReportType.EXPLICIT -> "Explicit Content"
ReportEvent.ReportType.NUDITY -> "Nudity"
ReportEvent.ReportType.PROFANITY -> "Profanity / Hateful speech"
ReportEvent.ReportType.SPAM -> "Spam"
ReportEvent.ReportType.IMPERSONATION -> "Impersonation"
ReportEvent.ReportType.ILLEGAL -> "Illegal Behavior"
else -> "Unknown"
}
}.joinToString(", ")
}.toSet().joinToString(", ")
Text(
text = reportType
)
Divider(
modifier = Modifier.padding(top = 10.dp),
modifier = Modifier.padding(top = 40.dp),
thickness = 0.25.dp
)
} else {
val eventContent = noteEvent.content
val eventContent = accountViewModel.decrypt(note)
val canPreview = note.author == account.userProfile()
|| (note.author?.let { account.userProfile().isFollowing(it) } ?: true )
|| !noteForReports.hasAnyReports()
@@ -602,6 +606,13 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
}) {
Text("Report Spam / Scam")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.PROFANITY);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Hateful Speech")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.IMPERSONATION);
note.author?.let { accountViewModel.hide(it, context) }
@@ -610,11 +621,11 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
Text("Report Impersonation")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.EXPLICIT);
accountViewModel.report(note, ReportEvent.ReportType.NUDITY);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Explicit Content")
Text("Report Nudity")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.ILLEGAL);
@@ -32,6 +32,19 @@ class ZapSetCard(val note: Note, val zapEvents: Map<Note, Note>): Card() {
override fun id() = note.idHex + "Z" + createdAt
}
class MultiSetCard(val note: Note, val boostEvents: List<Note>, val likeEvents: List<Note>, val zapEvents: Map<Note, Note>): Card() {
val createdAt = maxOf(
zapEvents.maxOfOrNull { it.value.event?.createdAt ?: 0 } ?: 0 ,
likeEvents.maxOfOrNull { it.event?.createdAt ?: 0 } ?: 0 ,
boostEvents.maxOfOrNull { it.event?.createdAt ?: 0 } ?: 0
)
override fun createdAt(): Long {
return createdAt
}
override fun id() = note.idHex + "X" + createdAt
}
class BoostSetCard(val note: Note, val boostEvents: List<Note>): Card() {
val createdAt = boostEvents.maxOf { it.event?.createdAt ?: 0 }
@@ -20,6 +20,7 @@ import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.ui.note.BoostSetCompose
import com.vitorpamplona.amethyst.ui.note.LikeSetCompose
import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ZapSetCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -120,6 +121,12 @@ private fun FeedLoaded(
navController = navController,
routeForLastRead = routeForLastRead
)
is MultiSetCard -> MultiSetCompose(
item,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
)
}
}
}
@@ -69,7 +69,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>): ViewModel() {
reactionsPerEvent.getOrPut(reactedPost, { mutableListOf() }).add(it)
}
val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) }
//val reactionCards = reactionsPerEvent.map { LikeSetCard(it.key, it.value) }
val zapsPerEvent = mutableMapOf<Note, MutableMap<Note, Note>>()
notes
@@ -84,7 +84,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>): ViewModel() {
}
}
val zapCards = zapsPerEvent.map { ZapSetCard(it.key, it.value) }
//val zapCards = zapsPerEvent.map { ZapSetCard(it.key, it.value) }
val boostsPerEvent = mutableMapOf<Note, MutableList<Note>>()
notes
@@ -95,11 +95,20 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>): ViewModel() {
boostsPerEvent.getOrPut(boostedPost, { mutableListOf() }).add(it)
}
val boostCards = boostsPerEvent.map { BoostSetCard(it.key, it.value) }
//val boostCards = boostsPerEvent.map { BoostSetCard(it.key, it.value) }
val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys
val multiCards = allBaseNotes.map {
MultiSetCard(it,
boostsPerEvent.get(it) ?: emptyList(),
reactionsPerEvent.get(it) ?: emptyList(),
zapsPerEvent.get(it) ?: emptyMap()
)
}
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is LnZapEvent }.map { NoteCard(it) }
return (reactionCards + boostCards + zapCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
return (multiCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
}
private fun updateFeed(notes: List<Card>) {
@@ -120,11 +129,9 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>): ViewModel() {
var handlerWaiting = AtomicBoolean()
@Synchronized
private fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {
@@ -87,11 +87,10 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>): ViewModel() {
}
private var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {
@@ -61,11 +61,9 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>): Vie
var handlerWaiting = AtomicBoolean()
@Synchronized
private fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {
@@ -83,11 +83,9 @@ class RelayFeedViewModel: ViewModel() {
var handlerWaiting = AtomicBoolean()
@Synchronized
private fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {
@@ -65,11 +65,9 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>): ViewModel() {
var handlerWaiting = AtomicBoolean()
@Synchronized
private fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {
@@ -87,31 +87,11 @@ fun TabKnown(accountViewModel: AccountViewModel, navController: NavController) {
ChatroomListKnownFeedFilter.account = account
val feedViewModel: NostrChatroomListKnownFeedViewModel = viewModel()
LaunchedEffect(Unit) {
LaunchedEffect(accountViewModel) {
NostrChatroomListDataSource.resetFilters()
feedViewModel.invalidateData()
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Chatroom List Start")
NostrChatroomListDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Chatroom List Stop")
NostrChatroomListDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
@@ -129,29 +109,9 @@ fun TabNew(accountViewModel: AccountViewModel, navController: NavController) {
ChatroomListNewFeedFilter.account = account
val feedViewModel: NostrChatroomListNewFeedViewModel = viewModel()
LaunchedEffect(Unit) {
LaunchedEffect(accountViewModel) {
NostrChatroomListDataSource.resetFilters()
feedViewModel.refresh() // refresh view
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Chatroom List Start")
NostrChatroomListDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Chatroom List Stop")
NostrChatroomListDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
feedViewModel.invalidateData() // refresh view
}
Column(Modifier.fillMaxHeight()) {
@@ -56,27 +56,6 @@ fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController)
feedViewModelReplies.invalidateData()
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Home Start")
NostrHomeDataSource.start()
feedViewModel.invalidateData()
feedViewModelReplies.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Home Stop")
NostrHomeDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
@@ -828,6 +828,13 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
}) {
Text("Report Spam / Scam")
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.PROFANITY);
user.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Hateful speech")
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION);
user.let { accountViewModel.hide(it, context) }
@@ -836,11 +843,11 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
Text("Report Impersonation")
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.EXPLICIT);
accountViewModel.report(user, ReportEvent.ReportType.NUDITY);
user.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Explicit Content")
Text("Report Nudity / Porn")
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL);
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
@@ -81,12 +82,13 @@ fun SearchScreen(accountViewModel: AccountViewModel, navController: NavControlle
val account = accountState?.account ?: return
GlobalFeedFilter.account = account
NostrGlobalDataSource.account = account
val feedViewModel: NostrGlobalFeedViewModel = viewModel()
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
feedViewModel.invalidateData()
delay(500)
feedViewModel.invalidateData()
}
DisposableEffect(accountViewModel) {