Compare commits

..
14 Commits
Author SHA1 Message Date
Vitor Pamplona 9fadab5bb9 v0.65.0 2023-07-04 11:45:35 -04:00
Vitor Pamplona ea768fb560 Base Streaming view 2023-07-04 11:44:28 -04:00
Vitor Pamplona 71cb18f360 v0.64.4 2023-07-03 08:53:30 -04:00
Vitor Pamplona 3a1694ac9a Renames Hex Check method for better understanding. 2023-07-03 08:36:33 -04:00
Vitor Pamplona 91c47b6e87 BugFix for delayed update of the last messages in the Messages screen 2023-07-03 08:36:12 -04:00
Vitor Pamplona fe4e03df39 v0.64.3 2023-07-02 16:38:28 -04:00
Vitor Pamplona d110b9fbbf BugFix for IndexOutOfBoundsException 2023-07-02 15:50:07 -04:00
Vitor Pamplona dc8c177f20 New Icon for verified follows 2023-07-02 15:47:07 -04:00
Vitor Pamplona 1beacd240e Fixing the lack of the checkmark in the verification icons 2023-07-02 15:46:41 -04:00
Vitor PamplonaandGitHub 083394b928 Merge pull request #485 from believethehype/main
Added #Cashu
2023-07-02 14:31:54 -04:00
Believethehype 6de8d9d7e0 Update HashtagIcon.kt 2023-07-02 17:02:58 +02:00
Vitor Pamplona fb4457e767 Fix for last PR 2023-07-01 18:58:01 -04:00
Vitor PamplonaandGitHub d4c1907349 Merge pull request #484 from AutumnSunshine/AutumnSunshine-updated-Tamil-translation
Update strings.xml for Tamil translation (values-ta)
2023-07-01 18:42:29 -04:00
AutumnSunshineandGitHub c92926f3ab Update strings.xml
Updated Tamil translations for newer additions, building on Vivek's earlier translation.
2023-07-02 03:22:15 +05:30
33 changed files with 1153 additions and 84 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 233
versionName "0.64.2"
versionCode 236
versionName "0.65.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -55,6 +55,7 @@ private object PrefKeys {
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
@@ -216,6 +217,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList)
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList)
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
@@ -256,6 +258,7 @@ object LocalPreferences {
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val zapAmountChoices = gson.fromJson(
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
@@ -349,6 +352,7 @@ object LocalPreferences {
defaultHomeFollowList = defaultHomeFollowList,
defaultStoriesFollowList = defaultStoriesFollowList,
defaultNotificationFollowList = defaultNotificationFollowList,
defaultDiscoveryFollowList = defaultDiscoveryFollowList,
zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
@@ -14,6 +14,7 @@ import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
@@ -66,6 +67,7 @@ object ServiceManager {
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
NostrDiscoveryDataSource.account = myAccount
ImageUploader.account = myAccount
// Notification Elements
@@ -87,6 +89,7 @@ object ServiceManager {
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomListDataSource.stop()
NostrDiscoveryDataSource.stop()
NostrSingleChannelDataSource.stop()
NostrSingleEventDataSource.stop()
@@ -62,6 +62,7 @@ class Account(
var defaultHomeFollowList: String = KIND3_FOLLOWS,
var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
var defaultNotificationFollowList: String = GLOBAL_FOLLOWS,
var defaultDiscoveryFollowList: String = GLOBAL_FOLLOWS,
var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
@@ -899,6 +900,12 @@ class Account(
saveable.invalidateData()
}
fun changeDefaultDiscoveryFollowList(name: String) {
defaultDiscoveryFollowList = name
live.invalidateData()
saveable.invalidateData()
}
fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts
live.invalidateData()
@@ -7,18 +7,19 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
fun checkForHashtagWithIcon(tag: String, primary: Color): HashtagIcon? {
return when (tag.lowercase()) {
"bitcoin", "btc", "timechain" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
"onyx" -> HashtagIcon(R.drawable.black_heart, "Onyx", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"cashu", "ecash", "nut", "nuts", "deeznuts" -> HashtagIcon(R.drawable.cashu, "Cashu", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
"tunestr", "music", "nowplaying" -> HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
"tunestr", "music", "nowplaying" -> HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp))
"weed", "weedstr", "420", "cannabis", "marijuana" -> HashtagIcon(R.drawable.weed, "Weed", Color.Unspecified, Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp))
else -> null
}
@@ -35,7 +35,7 @@ object LocalCache {
fun checkGetOrCreateUser(key: String): User? {
checkNotInMainThread()
if (isValidHexNpub(key)) {
if (isValidHex(key)) {
return getOrCreateUser(key)
}
return null
@@ -69,7 +69,7 @@ object LocalCache {
if (ATag.isATag(key)) {
return checkGetOrCreateAddressableNote(key)
}
if (isValidHexNpub(key)) {
if (isValidHex(key)) {
val note = getOrCreateNote(key)
val noteEvent = note.event
if (noteEvent is AddressableEvent) {
@@ -94,7 +94,7 @@ object LocalCache {
fun checkGetOrCreateChannel(key: String): Channel? {
checkNotInMainThread()
if (isValidHexNpub(key)) {
if (isValidHex(key)) {
return getOrCreateChannel(key) {
PublicChatChannel(key)
}
@@ -108,7 +108,7 @@ object LocalCache {
return null
}
private fun isValidHexNpub(key: String): Boolean {
private fun isValidHex(key: String): Boolean {
if (key.isBlank()) return false
if (key.contains(":")) return false
@@ -798,7 +798,7 @@ object LocalCache {
note.loadEvent(event, author, replyTo)
// Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}")
Log.d("CM", "New Chat Note (${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach {
@@ -279,6 +279,20 @@ open class Note(val idHex: String) {
}
}
fun publicZapAuthors(): Set<HexKey> {
// Zaps who the requester was the user
return zaps.mapNotNull {
it.key.author?.pubkeyHex
}.toSet()
}
fun reactionAuthors(): Set<HexKey> {
// Zaps who the requester was the user
return reactions.values.map {
it.mapNotNull { it.author?.pubkeyHex }
}.flatten().toSet()
}
fun isReactedBy(user: User): String? {
return reactions.filter {
it.value.any { it.author?.pubkeyHex == user.pubkeyHex }
@@ -0,0 +1,57 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
lateinit var account: Account
fun createContextualFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind, LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
limit = 1000
)
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind, LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 1000
)
)
}
val discoveryFeedChannel = requestNewChannel()
override fun updateChannelFilters() {
discoveryFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
}
}
@@ -25,15 +25,27 @@ class LiveActivitiesEvent(
fun streaming() = tags.firstOrNull { it.size > 1 && it[0] == "streaming" }?.get(1)
fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)
fun ends() = tags.firstOrNull { it.size > 1 && it[0] == "ends" }?.get(1)
fun status() = tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1)
fun status() = checkStatus(tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1))
fun currentParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "current_participants" }?.get(1)
fun totalParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "total_participants" }?.get(1)
fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) }
fun checkStatus(eventStatus: String?): String? {
return if (eventStatus == STATUS_LIVE && createdAt < Date().time / 1000 - (60 * 60 * 8)) { // 2 hours {
STATUS_ENDED
} else {
eventStatus
}
}
companion object {
const val kind = 30311
const val STATUS_LIVE = "live"
const val STATUS_PLANNED = "planned"
const val STATUS_ENDED = "ended"
fun create(
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
@@ -75,7 +75,6 @@ import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.theme.Font17SP
import com.vitorpamplona.amethyst.ui.theme.hashVerified
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -612,7 +611,7 @@ private fun HashVerificationSymbol(verifiedHash: Boolean, modifier: Modifier) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"Hash Verified",
tint = MaterialTheme.colors.hashVerified,
tint = Color.Unspecified,
modifier = Modifier.size(30.dp)
)
}
@@ -0,0 +1,66 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_ENDED
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_PLANNED
class DiscoverFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
}
override fun feed(): List<Note> {
val allChannelNotes = LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) }
val allMessageNotes = LocalCache.channels.values.map { it.notes.values }.flatten()
val notes = innerApplyFilter(allChannelNotes + allMessageNotes)
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = System.currentTimeMillis() / 1000
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val activities = collection
.asSequence()
.filter { it.event is LiveActivitiesEvent }
.filter { isGlobal || it.author?.pubkeyHex in followingKeySet }
.filter { account.isAcceptable(it) }
.filter { (it.createdAt() ?: 0) <= now }
.toSet()
return activities
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(
compareBy(
{ convertStatusToOrder((it.event as? LiveActivitiesEvent)?.status()) },
{ it.createdAt() },
{ it.idHex }
)
).reversed()
}
fun convertStatusToOrder(status: String?): Int {
return when (status) {
STATUS_LIVE -> 2
STATUS_PLANNED -> 1
STATUS_ENDED -> 0
else -> 0
}
}
}
@@ -7,6 +7,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
import java.util.Date
class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
@@ -41,7 +42,7 @@ class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<No
.asSequence()
.filter { it ->
val noteEvent = it.event
(noteEvent is LiveActivitiesEvent && noteEvent.createdAt > twoHrs && noteEvent.status() == "live" && OnlineChecker.isOnline(noteEvent.streaming())) &&
(noteEvent is LiveActivitiesEvent && noteEvent.createdAt > twoHrs && noteEvent.status() == STATUS_LIVE && OnlineChecker.isOnline(noteEvent.streaming())) &&
(isGlobal || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true
@@ -48,6 +48,7 @@ val bottomNavigationItems = listOf(
Route.Home,
Route.Message,
Route.Video,
Route.Discover,
Route.Notification
)
@@ -12,6 +12,7 @@ import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedLiveActivitiesViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
@@ -22,6 +23,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen
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.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen
@@ -42,6 +44,7 @@ fun AppNavigation(
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
newFeedViewModel: NostrChatroomListNewFeedViewModel,
videoFeedViewModel: NostrVideoFeedViewModel,
discoveryFeedViewModel: NostrDiscoverFeedViewModel,
notifFeedViewModel: NotificationViewModel,
userReactionsStatsModel: UserReactionsViewModel,
@@ -106,6 +109,16 @@ fun AppNavigation(
})
}
Route.Discover.let { route ->
composable(route.route, route.arguments, content = {
DiscoverScreen(
discoveryFeedViewModel = discoveryFeedViewModel,
accountViewModel = accountViewModel,
nav = nav
)
})
}
Route.Search.let { route ->
composable(route.route, route.arguments, content = {
SearchScreen(
@@ -118,6 +118,7 @@ private fun RenderTopRouteBar(
// Route.Profile.route -> TopBarWithBackButton(nav)
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel, nav)
Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel, nav)
Route.Discover.base -> DiscoveryTopBar(followLists, scaffoldState, accountViewModel, nav)
Route.Notification.base -> NotificationTopBar(followLists, scaffoldState, accountViewModel, nav)
else -> MainTopBar(scaffoldState, accountViewModel, nav)
}
@@ -178,6 +179,23 @@ fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: Scaffold
}
}
@Composable
fun DiscoveryTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
GenericTopBar(scaffoldState, accountViewModel, nav) { accountViewModel ->
val list by accountViewModel.accountLiveData.map {
it.account.defaultDiscoveryFollowList
}.observeAsState(GLOBAL_FOLLOWS)
FollowList(
followLists,
list,
true
) { listName ->
accountViewModel.account.changeDefaultDiscoveryFollowList(listName)
}
}
}
@Composable
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
GenericTopBar(scaffoldState, accountViewModel, nav) {
@@ -57,6 +57,11 @@ sealed class Route(
icon = R.drawable.ic_video
)
object Discover : Route(
route = "Discover",
icon = R.drawable.ic_sensors
)
object Notification : Route(
route = "Notification",
icon = R.drawable.ic_notifications,
@@ -0,0 +1,480 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.BottomStart
import androidx.compose.ui.Alignment.Companion.TopEnd
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.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_ENDED
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_PLANNED
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChannelCardCompose(
baseNote: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isBlank by baseNote.live().metadata.map {
it.note.event == null
}.distinctUntilChanged().observeAsState(baseNote.event == null)
Crossfade(targetState = isBlank) {
if (it) {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
modifier.combinedClickable(
onClick = { },
onLongClick = showPopup
)
},
false
)
}
} else {
CheckHiddenChannelCardCompose(
baseNote,
routeForLastRead,
modifier,
parentBackgroundColor,
accountViewModel,
nav
)
}
}
}
@Composable
fun CheckHiddenChannelCardCompose(
note: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isHidden by accountViewModel.accountLiveData.map {
accountViewModel.isNoteHidden(note)
}.distinctUntilChanged().observeAsState(accountViewModel.isNoteHidden(note))
Crossfade(targetState = isHidden) {
if (!it) {
LoadedChannelCardCompose(
note,
routeForLastRead,
modifier,
parentBackgroundColor,
accountViewModel,
nav
)
}
}
}
@Composable
fun LoadedChannelCardCompose(
note: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
)
}
val scope = rememberCoroutineScope()
WatchForReports(note, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
val newState = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports)
scope.launch(Dispatchers.Main) {
state = newState
}
}
}
Crossfade(targetState = state) {
RenderChannelCardReportState(
it,
note,
routeForLastRead,
modifier,
parentBackgroundColor,
accountViewModel,
nav
)
}
}
@Composable
fun RenderChannelCardReportState(
state: NoteComposeReportState,
note: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var showReportedNote by remember { mutableStateOf(false) }
Crossfade(targetState = !state.isAcceptable && !showReportedNote) { showHiddenNote ->
if (showHiddenNote) {
HiddenNote(
state.relevantReports,
accountViewModel,
modifier,
false,
nav,
onClick = { showReportedNote = true }
)
} else {
NormalChannelCard(
note,
routeForLastRead,
modifier,
parentBackgroundColor,
accountViewModel,
nav
)
}
}
}
@Composable
fun NormalChannelCard(
baseNote: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
CheckNewAndRenderChannelCard(
baseNote,
routeForLastRead,
modifier,
parentBackgroundColor,
accountViewModel,
showPopup,
nav
)
}
}
@Composable
private fun CheckNewAndRenderChannelCard(
baseNote: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
showPopup: () -> Unit,
nav: (String) -> Unit
) {
val newItemColor = MaterialTheme.colors.newItemBackgroundColor
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = remember { mutableStateOf<Color>(defaultBackgroundColor) }
LaunchedEffect(key1 = routeForLastRead, key2 = parentBackgroundColor?.value) {
launch(Dispatchers.IO) {
routeForLastRead?.let {
val lastTime = accountViewModel.account.loadLastRead(it)
val createdAt = baseNote.createdAt()
if (createdAt != null) {
accountViewModel.account.markAsRead(it, createdAt)
val isNew = createdAt > lastTime
val newBackgroundColor = if (isNew) {
if (parentBackgroundColor != null) {
newItemColor.compositeOver(parentBackgroundColor.value)
} else {
newItemColor.compositeOver(defaultBackgroundColor)
}
} else {
parentBackgroundColor?.value ?: defaultBackgroundColor
}
if (newBackgroundColor != backgroundColor.value) {
launch(Dispatchers.Main) {
backgroundColor.value = newBackgroundColor
}
}
}
} ?: run {
val newBackgroundColor = parentBackgroundColor?.value ?: defaultBackgroundColor
if (newBackgroundColor != backgroundColor.value) {
launch(Dispatchers.Main) {
backgroundColor.value = newBackgroundColor
}
}
}
}
}
ClickableNote(
baseNote = baseNote,
backgroundColor = backgroundColor,
modifier = modifier,
accountViewModel = accountViewModel,
showPopup = showPopup,
nav = nav
) {
InnerChannelCardWithReactions(
baseNote = baseNote,
accountViewModel = accountViewModel,
nav = nav
)
}
}
@Composable
fun InnerChannelCardWithReactions(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Column() {
RenderNoteRow(
baseNote,
accountViewModel,
nav
)
}
}
@Composable
private fun RenderNoteRow(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
when (remember { baseNote.event }) {
is LiveActivitiesEvent -> {
RenderLiveActivityThumb(baseNote, accountViewModel, nav)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return
val eventUpdates by baseNote.live().metadata.observeAsState()
val media = remember(eventUpdates) { noteEvent.streaming() }
val cover by remember(eventUpdates) {
derivedStateOf {
noteEvent.image()?.ifBlank { null }
}
}
val subject = remember(eventUpdates) { noteEvent.title()?.ifBlank { null } }
val content = remember(eventUpdates) { noteEvent.summary() }
val participants = remember(eventUpdates) { noteEvent.participants() }
val status = remember(eventUpdates) { noteEvent.status() }
var isOnline by remember { mutableStateOf(false) }
LaunchedEffect(key1 = media) {
launch(Dispatchers.IO) {
val newIsOnline = OnlineChecker.isOnline(media)
if (isOnline != newIsOnline) {
isOnline = newIsOnline
}
}
}
var participantUsers by remember {
mutableStateOf<ImmutableList<User>>(
persistentListOf()
)
}
LaunchedEffect(key1 = eventUpdates) {
launch(Dispatchers.IO) {
val channel = LocalCache.getChannelIfExists(baseNote.idHex)
val repliesByFollows = channel?.notes?.mapNotNull { it.value.author?.pubkeyHex }?.toSet() ?: emptySet()
val zappers = baseNote.publicZapAuthors()
val likeAuthors = baseNote.reactionAuthors()
val allParticipants = (repliesByFollows + zappers + likeAuthors).filter { accountViewModel.isFollowing(it) }
val newParticipantUsers = (
participants.mapNotNull { part ->
if (part.key != baseNote.author?.pubkeyHex) {
LocalCache.checkGetOrCreateUser(part.key)
} else {
null
}
} + allParticipants.mapNotNull {
if (it != baseNote.author?.pubkeyHex) {
LocalCache.checkGetOrCreateUser(it)
} else {
null
}
}
).toImmutableList()
if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
}
}
}
Column(
modifier = Modifier.fillMaxWidth()
) {
Box(
contentAlignment = TopEnd,
modifier = Modifier
.aspectRatio(ratio = 16f / 9f)
.fillMaxWidth()
) {
cover?.let {
AsyncImage(
model = it,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(QuoteBorder)
)
} ?: run {
baseNote.author?.let {
DisplayAuthorBanner(it)
}
}
Box(Modifier.padding(10.dp)) {
Crossfade(targetState = status) {
when (it) {
STATUS_LIVE -> {
if (media.isNullOrBlank()) {
LiveFlag()
} else if (isOnline) {
LiveFlag()
} else {
OfflineFlag()
}
}
STATUS_ENDED -> {
EndedFlag()
}
STATUS_PLANNED -> {
ScheduledFlag()
}
else -> {
EndedFlag()
}
}
}
}
Box(Modifier.padding(10.dp).align(BottomStart)) {
if (participantUsers.isNotEmpty()) {
FlowRow() {
participantUsers.forEach {
ClickableUserPicture(it, Size35dp, accountViewModel)
}
}
}
}
}
ChannelHeader(
channelHex = remember { baseNote.idHex },
showVideo = false,
showBottomDiviser = false,
showFlag = false,
modifier = remember {
Modifier.padding(start = 0.dp, end = 0.dp, top = 5.dp, bottom = 5.dp)
},
accountViewModel = accountViewModel,
nav = nav
)
}
}
@Composable
fun DisplayAuthorBanner(author: User) {
val picture by author.live().metadata.map {
it.user.info?.banner?.ifBlank { null } ?: it.user.info?.picture?.ifBlank { null }
}.observeAsState()
AsyncImage(
model = picture,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(QuoteBorder)
)
}
@@ -150,19 +150,19 @@ private fun ChannelRoomCompose(
nav: (String) -> Unit
) {
val authorState by note.author!!.live().metadata.observeAsState()
val authorName = remember(authorState) {
val authorName = remember(note, authorState) {
authorState?.user?.toBestDisplayName()
}
val chanHex = remember { channel.idHex }
val channelState by channel.live.observeAsState()
val channelPicture by remember(channelState) {
val channelPicture by remember(note, channelState) {
derivedStateOf {
channel.profilePicture()
}
}
val channelName by remember(channelState) {
val channelName by remember(note, channelState) {
derivedStateOf {
channel.toBestDisplayName()
}
@@ -182,7 +182,7 @@ private fun ChannelRoomCompose(
noteEvent?.content()
}
var hasNewMessages = remember { mutableStateOf<Boolean>(false) }
val hasNewMessages = remember { mutableStateOf<Boolean>(false) }
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->
if (hasNewMessages.value != newHasNewMessages) {
@@ -182,7 +182,7 @@ private fun NIP05VerifiedSymbol(nip05Verified: MutableState<Boolean?>) {
)
} else if (nip05Verified.value == true) {
Icon(
painter = painterResource(R.drawable.ic_verified),
painter = painterResource(R.drawable.ic_verified_transparent),
"NIP-05 Verified",
tint = Nip05.copy(0.52f),
modifier = Modifier
@@ -218,7 +218,7 @@ fun DisplayNip05ProfileStatus(user: User) {
)
} else if (nip05Verified == true) {
Icon(
painter = painterResource(R.drawable.ic_verified),
painter = painterResource(R.drawable.ic_verified_transparent),
"NIP-05 Verified",
tint = Nip05,
modifier = Modifier.size(16.dp)
@@ -108,6 +108,8 @@ import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_PLANNED
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.Participant
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
@@ -152,7 +154,6 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Following
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
@@ -539,7 +540,7 @@ private fun CheckNewAndRenderNote(
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun ClickableNote(
fun ClickableNote(
baseNote: Note,
modifier: Modifier,
backgroundColor: MutableState<Color>,
@@ -550,7 +551,7 @@ private fun ClickableNote(
) {
val scope = rememberCoroutineScope()
val updatedModifier = remember(backgroundColor.value) {
val updatedModifier = remember(baseNote, backgroundColor.value) {
modifier
.combinedClickable(
onClick = {
@@ -1786,7 +1787,7 @@ private fun ReplyNoteComposition(
}
@Composable
private fun SecondUserInfoRow(
fun SecondUserInfoRow(
note: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -1810,7 +1811,7 @@ private fun SecondUserInfoRow(
}
@Composable
private fun FirstUserInfoRow(
fun FirstUserInfoRow(
baseNote: Note,
showAuthorPicture: Boolean,
accountViewModel: AccountViewModel,
@@ -1855,7 +1856,7 @@ private fun BoostedMark() {
}
@Composable
private fun MoreOptionsButton(
fun MoreOptionsButton(
baseNote: Note,
accountViewModel: AccountViewModel
) {
@@ -2691,12 +2692,12 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
Crossfade(targetState = status) {
when (it) {
"live" -> {
STATUS_LIVE -> {
if (isOnline) {
LiveFlag()
}
}
"planned" -> {
STATUS_PLANNED -> {
ScheduledFlag()
}
}
@@ -2745,7 +2746,7 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
}
media?.let { media ->
if (status == "live") {
if (status == STATUS_LIVE) {
if (isOnline) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -2842,7 +2843,7 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountView
}
@Composable
private fun CreateImageHeader(
fun CreateImageHeader(
note: Note,
accountViewModel: AccountViewModel
) {
@@ -3220,10 +3221,10 @@ fun FollowingIcon(iconSize: Dp) {
}
Icon(
painter = painterResource(R.drawable.ic_verified),
stringResource(id = R.string.following),
painter = painterResource(R.drawable.verified_follow_shield),
contentDescription = stringResource(id = R.string.following),
modifier = modifier,
tint = Following
tint = Color.Unspecified
)
}
@@ -7,5 +7,6 @@ fun ByteArray.toShortenHex(): String {
}
fun String.toShortenHex(): String {
if (length <= 16) return this
return replaceRange(8, length - 8, ":")
}
@@ -16,6 +16,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -33,20 +34,20 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier, showPlayButton: Boolean = true) {
val authorState by baseNote.live().metadata.map {
it.note.author
}.observeAsState(baseNote.author)
Crossfade(targetState = authorState, modifier = weight) {
it?.let {
UsernameDisplay(it, weight)
UsernameDisplay(it, weight, showPlayButton)
}
}
}
@Composable
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton: Boolean = true) {
val npubDisplay by remember {
derivedStateOf {
baseUser.pubkeyDisplayHex()
@@ -59,7 +60,7 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
Crossfade(targetState = userMetadata, modifier = weight) {
if (it != null) {
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight)
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight, showPlayButton)
} else {
NPubDisplay(npubDisplay, weight)
}
@@ -72,14 +73,15 @@ private fun UserNameDisplay(
bestDisplayName: String?,
npubDisplay: String,
tags: ImmutableListOfLists<String>?,
modifier: Modifier
modifier: Modifier,
showPlayButton: Boolean = true
) {
if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) {
UserAndUsernameDisplay(bestDisplayName, tags, bestUserName, modifier)
UserAndUsernameDisplay(bestDisplayName, tags, bestUserName, modifier, showPlayButton)
} else if (bestDisplayName != null) {
UserDisplay(bestDisplayName, tags, modifier)
UserDisplay(bestDisplayName, tags, modifier, showPlayButton)
} else if (bestUserName != null) {
UserDisplay(bestUserName, tags, modifier)
UserDisplay(bestUserName, tags, modifier, showPlayButton)
} else {
NPubDisplay(npubDisplay, modifier)
}
@@ -100,9 +102,10 @@ private fun NPubDisplay(npubDisplay: String, modifier: Modifier) {
private fun UserDisplay(
bestDisplayName: String,
tags: ImmutableListOfLists<String>?,
modifier: Modifier
modifier: Modifier,
showPlayButton: Boolean = true
) {
Row(modifier = modifier) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
@@ -111,8 +114,10 @@ private fun UserDisplay(
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
if (showPlayButton) {
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
}
}
}
@@ -121,9 +126,10 @@ private fun UserAndUsernameDisplay(
bestDisplayName: String,
tags: ImmutableListOfLists<String>?,
bestUserName: String,
modifier: Modifier
modifier: Modifier,
showPlayButton: Boolean = true
) {
Row(modifier = modifier) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
@@ -138,8 +144,10 @@ private fun UserAndUsernameDisplay(
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
if (showPlayButton) {
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
}
}
}
@@ -21,6 +21,7 @@ import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DiscoverFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
@@ -65,6 +66,15 @@ class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFil
}
}
}
class NostrDiscoverFeedViewModel(val account: Account) : FeedViewModel(DiscoverFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrDiscoverFeedViewModel : ViewModel> create(modelClass: Class<NostrDiscoverFeedViewModel>): NostrDiscoverFeedViewModel {
return NostrDiscoverFeedViewModel(account) as NostrDiscoverFeedViewModel
}
}
}
class NostrThreadFeedViewModel(val noteId: String) : FeedViewModel(ThreadFeedFilter(noteId)) {
class Factory(val noteId: String) : ViewModelProvider.Factory {
override fun <NostrThreadFeedViewModel : ViewModel> create(modelClass: Class<NostrThreadFeedViewModel>): NostrThreadFeedViewModel {
@@ -17,6 +17,7 @@ object ScrollStateKeys {
const val GLOBAL_SCREEN = "Global"
const val NOTIFICATION_SCREEN = "Notifications"
const val VIDEO_SCREEN = "Video"
const val DISCOVER_SCREEN = "Discover"
val HOME_FOLLOWS = Route.Home.base + "Follows"
val HOME_REPLIES = Route.Home.base + "FollowsReplies"
}
@@ -13,6 +13,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
@@ -241,6 +242,10 @@ class AccountViewModel(val account: Account) : ViewModel() {
return account.userProfile().isFollowingCached(user)
}
fun isFollowing(user: HexKey): Boolean {
return account.userProfile().isFollowingCached(user)
}
val hideDeleteRequestDialog: Boolean
get() = account.hideDeleteRequestDialog
@@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
@@ -516,6 +517,7 @@ fun ChannelHeader(
channelHex: String,
showVideo: Boolean,
showBottomDiviser: Boolean,
showFlag: Boolean = true,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -535,6 +537,7 @@ fun ChannelHeader(
it,
showVideo,
showBottomDiviser,
showFlag,
modifier,
accountViewModel,
nav
@@ -547,6 +550,7 @@ fun ChannelHeader(
baseChannel: Channel,
showVideo: Boolean,
showBottomDiviser: Boolean,
showFlag: Boolean = true,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -564,27 +568,33 @@ fun ChannelHeader(
val channelState by baseChannel.live.observeAsState()
val channel = remember(channelState) { channelState?.channel } ?: return
val streamingUrl by remember(channelState) {
derivedStateOf {
val activity = channel as? LiveActivitiesChannel
val description = activity?.info?.title()
val url = activity?.info?.streaming()
if (url != null) {
ZoomableUrlVideo(url, description = description)
} else {
null
if (showVideo) {
val streamingUrl by remember(channelState) {
derivedStateOf {
val activity = channel as? LiveActivitiesChannel
val description = activity?.info?.title()
val url = activity?.info?.streaming()
if (url != null) {
ZoomableUrlVideo(url, description = description)
} else {
null
}
}
}
streamingUrl?.let {
CheckIfUrlIsOnline(it.url) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = remember { Modifier.heightIn(max = 300.dp) }
) {
ZoomableContentView(
content = it
)
}
}
}
}
if (streamingUrl != null && showVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = remember { Modifier.heightIn(max = 300.dp) }) {
ZoomableContentView(
content = streamingUrl!!
)
}
}
Column(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (channel is LiveActivitiesChannel) {
@@ -653,7 +663,7 @@ fun ChannelHeader(
ChannelActionOptions(channel, accountViewModel, nav)
}
if (channel is LiveActivitiesChannel) {
LiveChannelActionOptions(channel, accountViewModel, nav)
LiveChannelActionOptions(channel, showFlag, accountViewModel, nav)
}
}
}
@@ -702,12 +712,13 @@ private fun ChannelActionOptions(
@Composable
private fun LiveChannelActionOptions(
channel: LiveActivitiesChannel,
showFlag: Boolean = true,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isLive by remember(channel) {
derivedStateOf {
channel.info?.status() == "live"
channel.info?.status() == STATUS_LIVE
}
}
@@ -716,7 +727,7 @@ private fun LiveChannelActionOptions(
}
note?.let {
if (isLive) {
if (showFlag && isLive) {
LiveFlag()
Spacer(modifier = StdHorzSpacer)
}
@@ -0,0 +1,160 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
import com.vitorpamplona.amethyst.ui.note.ChannelCardCompose
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
import com.vitorpamplona.amethyst.ui.screen.FeedError
import com.vitorpamplona.amethyst.ui.screen.FeedState
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
@Composable
fun DiscoverScreen(
discoveryFeedViewModel: NostrDiscoverFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val lifeCycleOwner = LocalLifecycleOwner.current
WatchAccountForDiscoveryScreen(discoveryViewModel = discoveryFeedViewModel, accountViewModel = accountViewModel)
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Discovery Start")
NostrDiscoveryDataSource.start()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Discovery Stop")
NostrDiscoveryDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
RefresheableView(discoveryFeedViewModel, true) {
SaveableFeedState(discoveryFeedViewModel, scrollStateKey = ScrollStateKeys.DISCOVER_SCREEN) { listState ->
RenderDiscoverFeed(discoveryFeedViewModel, accountViewModel, listState, nav)
}
}
}
}
}
@Composable
private fun RenderDiscoverFeed(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
listState: LazyListState,
nav: (String) -> Unit
) {
val feedState by viewModel.feedContent.collectAsState()
Crossfade(
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
viewModel.invalidateData()
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
viewModel.invalidateData()
}
}
is FeedState.Loaded -> {
DiscoverFeedLoaded(
state,
listState,
accountViewModel,
nav
)
}
is FeedState.Loading -> {
LoadingFeed()
}
}
}
}
@Composable
fun WatchAccountForDiscoveryScreen(discoveryViewModel: NostrDiscoverFeedViewModel, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState()
LaunchedEffect(accountViewModel, accountState?.account?.defaultDiscoveryFollowList) {
NostrDiscoveryDataSource.resetFilters()
discoveryViewModel.checkKeysInvalidateDataAndSendToTop()
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DiscoverFeedLoaded(
state: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
val defaultModifier = remember {
Modifier.padding(10.dp).animateItemPlacement()
}
ChannelCardCompose(
item,
modifier = defaultModifier,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@@ -33,6 +33,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
@@ -211,12 +212,22 @@ private fun FeedLoaded(
@Composable
fun CheckIfLiveActivityIsOnline(note: Note, whenOnline: @Composable () -> Unit) {
val noteState by note.live().metadata.observeAsState()
val url by note.live().metadata.map {
(note.event as? LiveActivitiesEvent)?.streaming()
}.observeAsState()
url?.let {
CheckIfUrlIsOnline(it, whenOnline)
}
}
@Composable
fun CheckIfUrlIsOnline(url: String, whenOnline: @Composable () -> Unit) {
var online by remember { mutableStateOf(false) }
LaunchedEffect(key1 = noteState) {
LaunchedEffect(key1 = url) {
launch(Dispatchers.IO) {
online = OnlineChecker.isOnline((note.event as? LiveActivitiesEvent)?.streaming())
online = OnlineChecker.isOnline(url)
}
}
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedLiveActivitiesViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
@@ -98,6 +99,11 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
factory = NostrVideoFeedViewModel.Factory(accountViewModel.account)
)
val discoveryFeedViewModel: NostrDiscoverFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrDiscoveryFeedViewModel",
factory = NostrDiscoverFeedViewModel.Factory(accountViewModel.account)
)
val notifFeedViewModel: NotificationViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NotificationViewModel",
factory = NotificationViewModel.Factory(accountViewModel.account)
@@ -136,6 +142,9 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
Route.Video.base -> {
videoFeedViewModel.sendToTop()
}
Route.Discover.base -> {
discoveryFeedViewModel.sendToTop()
}
Route.Notification.base -> {
notifFeedViewModel.invalidateDataAndSendToTop()
}
@@ -184,6 +193,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
knownFeedViewModel,
newFeedViewModel,
videoFeedViewModel,
discoveryFeedViewModel,
notifFeedViewModel,
userReactionsStatsModel,
navController,
@@ -51,6 +51,7 @@ val Size17dp = 17.dp
val Size18dp = 18.dp
val Size19dp = 19.dp
val Size20dp = 20.dp
val Size22dp = 22.dp
val Size24dp = 24.dp
val Size25dp = 25.dp
val Size30dp = 30.dp
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#9c9c9c"
android:pathData="M142.11,817.89Q77.63,753.42 40.02,666.86 2.41,580.29 2.41,480q0,-100.29 37.61,-186.86 37.61,-86.56 102.08,-151.04l51.34,51.34q-54.92,54.92 -87.16,128.35 -32.24,73.43 -32.24,158.2 0,84.77 31.64,157.6 31.64,72.83 87.76,128.95zM277.03,682.97q-38.21,-38.21 -60.89,-90.14 -22.69,-51.94 -22.69,-112.83 0,-60.89 22.69,-112.83 22.69,-51.94 60.89,-90.14l51.34,51.34q-28.66,28.66 -45.97,67.46 -17.31,38.8 -17.31,84.17 0,45.37 16.72,83.58 16.72,38.21 46.56,68.06zM480,575.52q-39.4,0 -67.46,-28.06 -28.06,-28.06 -28.06,-67.46 0,-39.4 28.06,-67.46 28.06,-28.06 67.46,-28.06 39.4,0 67.46,28.06 28.06,28.06 28.06,67.46 0,39.4 -28.06,67.46 -28.06,28.06 -67.46,28.06zM682.97,682.97 L631.63,631.63q28.66,-28.66 45.97,-67.46 17.31,-38.8 17.31,-84.17 0,-45.37 -16.72,-83.58 -16.72,-38.21 -46.56,-68.06l51.34,-51.34q38.21,38.21 60.89,90.14 22.69,51.94 22.69,112.83 0,59.7 -22.69,111.64 -22.69,51.94 -60.89,91.34zM817.89,817.89 L766.55,766.55q54.92,-54.92 87.16,-128.35 32.24,-73.43 32.24,-158.2 0,-84.77 -31.64,-157.6 -31.64,-72.83 -87.76,-128.95l51.34,-51.34q64.47,65.67 102.08,152.23 37.61,86.56 37.61,185.66 0,99.1 -37.61,185.66 -37.61,86.56 -102.08,152.23z"
android:strokeWidth="1.19396"/>
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+171 -12
View File
@@ -23,7 +23,7 @@
<string name="copy_note_id">குறிப்பின் IDஐ நகலெடுக்கவும்</string>
<string name="broadcast">ஒளிபரப்பு</string>
<string name="request_deletion">நீக்குவதற்கு கோரிக்கை செய்</string>
<string name="block_hide_user">பயனரை முடக்கு + மறை</string>
<string name="block_hide_user">யூஸரை முடக்கு + மறை</string>
<string name="report_spam_scam">ஸ்பேம் / மோசடி என்று புகாரளிக்கவும்</string>
<string name="report_impersonation">ஆள்மாறாட்டத்தைப் புகாரளிக்கவும்</string>
<string name="report_explicit_content">வெளிப்படையான உள்ளடக்கத்தைப் புகாரளிக்கவும்</string>
@@ -83,8 +83,8 @@
<string name="add_a_relay">ஒரு ரிலேவை சேர்</string>
<string name="display_name">காட்சிப் பெயர்</string>
<string name="my_display_name">எனது காட்சிப் பெயர்</string>
<string name="username">பயனர் பெயர்</string>
<string name="my_username">எனது பயனர் பெயர்</string>
<string name="username">யூஸர் பெயர்</string>
<string name="my_username">எனது யூஸர் பெயர்</string>
<string name="about_me">என்னைப் பற்றி</string>
<string name="avatar_url">சுயவிவரப் படம் URL</string>
<string name="banner_url">பேனர் URL</string>
@@ -95,7 +95,7 @@
<string name="failed_to_save_the_image">படத்தை சேமிப்பது தோல்வியடைந்தது</string>
<string name="upload_image">படத்தை பதிவேற்று</string>
<string name="uploading">பதிவேற்றம்...</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">யூஸர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை</string>
<string name="reply_here">"இங்கு பதிலளிக்கவும்.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">குறிப்பு ID ஐ கிளிப்போர்டில் பிரதி எடுக்கும்</string>
<string name="copy_channel_id_note_to_the_clipboard">அலைவரிசை ID ஐ கிளிப்போர்டில் பிரதி எடு</string>
@@ -103,7 +103,7 @@
<string name="join">சேர்</string>
<string name="known">தெரிந்த</string>
<string name="new_requests">புதிய கோரிக்கைகள்</string>
<string name="blocked_users">முடக்கப்பட்ட பயனர்கள்</string>
<string name="blocked_users">முடக்கப்பட்ட யூஸர்கள்</string>
<string name="new_threads">புதிய நூல்கள்</string>
<string name="conversations">உரையாடல்கள்</string>
<string name="notes">குறிப்புகள்</string>
@@ -119,13 +119,13 @@
<string name="copies_the_public_key_to_the_clipboard_for_sharing">பகிர்வுக்காக கிளிப்போர்டுக்கு பொதுசாவியை நகலெடுக்கிறது</string>
<string name="copy_public_key_npub_to_the_clipboard">கிளிப்போர்டுக்கு பொதுசாவியை (NPUB) நகலெடுக்கவும்</string>
<string name="send_a_direct_message">நேரடி செய்தியை அனுப்பவும்</string>
<string name="edits_the_user_s_metadata">பயனரின்தகவலை திருத்துகிறது</string>
<string name="edits_the_user_s_metadata">யூஸரின்தகவலை திருத்துகிறது</string>
<string name="follow">பின்தொடர்</string>
<string name="follow_back">பதிலுக்கு பின்தொடரவும்</string>
<string name="unblock">தடையை நீக்கு</string>
<string name="copy_user_id">பயனர் IDயை நகலெடுக்கவும்</string>
<string name="unblock_user">பயனரைத் தடைசெய்க</string>
<string name="npub_hex_username">"npub, hex, பயனர்பெயர் "</string>
<string name="copy_user_id">யூஸர் IDயை நகலெடுக்கவும்</string>
<string name="unblock_user">யூஸர் தடையை நீக்க</string>
<string name="npub_hex_username">"npub, hex, யூஸர்பெயர் "</string>
<string name="clear">அழி</string>
<string name="app_logo">பயன்பாட்டு லோகோ</string>
<string name="nsec_npub_hex_private_key">nsec / npub / hex தனிப்பட்ட சாவி</string>
@@ -226,8 +226,8 @@
<string name="report_dialog_impersonation">தீங்கிழைக்கும் ஆள்மாறாட்டம்</string>
<string name="report_dialog_nudity">நிர்வாணம் அல்லது கிராஃபிக் உள்ளடக்கம்</string>
<string name="report_dialog_illegal">சட்டவிரோத நடத்தை</string>
<string name="report_dialog_blocking_a_user">ஒரு பயனரைத் தடுப்பது உங்கள் பயன்பாட்டில் அவர்களின் உள்ளடக்கத்தை மறைக்கும். நீங்கள் தடுக்கும் நபர்கள் உட்பட உங்கள் குறிப்புகள் இன்னும் பகிரங்கமாகக் காணப்படுகின்றன. தடுக்கப்பட்ட பயனர்கள் பாதுகாப்பு வடிப்பான்கள் திரையில் பட்டியலிடப்பட்டுள்ளனர்.</string>
<string name="report_dialog_block_hide_user_btn">பயனரைத் தடுத்து மறைக்கவும்</string>
<string name="report_dialog_blocking_a_user">ஒரு யூஸரைத் தடுப்பது உங்கள் பயன்பாட்டில் அவர்களின் உள்ளடக்கத்தை மறைக்கும். நீங்கள் தடுக்கும் நபர்கள் உட்பட உங்கள் குறிப்புகள் இன்னும் பகிரங்கமாகக் காணப்படுகின்றன. தடுக்கப்பட்ட யூஸர்கள் பாதுகாப்பு வடிப்பான்கள் திரையில் பட்டியலிடப்பட்டுள்ளனர்.</string>
<string name="report_dialog_block_hide_user_btn">யூஸரைத் தடுத்து மறைக்கவும்</string>
<string name="report_dialog_report_btn">தகாதது என பதிவுசெய்</string>
<string name="report_dialog_reminder_public">இடுகையிடப்பட்ட அனைத்து அறிக்கைகளும் பகிரங்கமாக தெரியும்.</string>
<string name="report_dialog_additional_reason_placeholder">உங்கள் அறிக்கையைப் பற்றிய கூடுதல் சூழலை விருப்பமாக வழங்கவும்…</string>
@@ -267,7 +267,7 @@
<string name="poll_closing_time_days">நாட்களில்</string>
<string name="poll_is_closed">புதிய வாக்குகளுக்கு வாக்கெடுப்பு மூடப்பட்டுள்ளது</string>
<string name="poll_zap_amount">ஜாப் தொகை</string>
<string name="one_vote_per_user_on_atomic_votes">இந்த வகை வாக்கெடுப்பில் ஒரு பயனருக்கு ஒரு வாக்கு மட்டுமே அனுமதிக்கப்படுகிறது</string>
<string name="one_vote_per_user_on_atomic_votes">இந்த வகை வாக்கெடுப்பில் ஒரு யூஸருக்கு ஒரு வாக்கு மட்டுமே அனுமதிக்கப்படுகிறது</string>
<string name="looking_for_event">"நிகழ்வு %1$s ஐத் தேடி"</string>
<string name="custom_zaps_add_a_message">ஒரு பொது செய்தியைச் சேர்க்கவும்</string>
<string name="custom_zaps_add_a_message_private">ஒரு தனிப்பட்ட செய்தியைச் சேர்க்கவும்</string>
@@ -283,4 +283,163 @@
<string name="add_content">உருவாக்கி சேர்க்கவும்</string>
<string name="content_description">உள்ளடக்கங்களின் விளக்கம்</string>
<string name="content_description_example">சூரியன் மறையும் வேளையில் வெள்ளை மண் நிறைந்த கடற்கரையில் ஒரு நீலப் படகு</string>
<string name="zap_type">ஜாப் டைப்</string>
<string name="zap_type_explainer">அனைத்து வகைகளுக்கான ஜாப் டைப்</string>
<string name="zap_type_public">பொது</string>
<string name="zap_type_public_explainer">அனைவரும் ஜாப் பரிவர்த்தனையையும் செய்தியையும் பார்க்கமுடியும்</string>
<string name="zap_type_private">தனிப்பட்ட</string>
<string name="zap_type_private_explainer">பெறுபவரும் அனுப்புபவரும் மட்டும் ஒருவரை ஒருவர் மற்றும் ஜாப் செய்தியை பார்க்கமுடியும்</string>
<string name="zap_type_anonymous">பெயர் ரகசியமாக</string>
<string name="zap_type_anonymous_explainer">பெறுபவருக்கும் மற்றவருக்கும் ஜாப் அனுப்புபவர் யார் என்று தெரியாது</string>
<string name="zap_type_nonzap">அல்லாத-ஜாப்</string>
<string name="zap_type_nonzap_explainer">நாஸ்டரில் எந்த சுவடும் இன்றி, லைட்நிங்கில் மட்டும் உள்ளது</string>
<string name="file_server">ஃபைல் சர்வர்</string>
<string name="zap_forward_lnAddress">LN முகவரி அல்லது @யூஸர்</string>
<string name="upload_server_imgur">imgur.com - நம்பகமானது</string>
<string name="upload_server_imgur_explainer">imgur ஃபைலை மாற்றமுடியும்</string>
<string name="upload_server_nostrimg">nostrimg.com - நம்பகமானது</string>
<string name="upload_server_nostrimg_explainer">NostrImg ஃபைலை மாற்றமுடியும்</string>
<string name="upload_server_nostrbuild">nostr.build - நம்பகமானது</string>
<string name="upload_server_nostrbuild_explainer">nostr.build ஃபைலை மாற்றமுடியும்</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev - நம்பகமானது</string>
<string name="upload_server_nostrfilesdev_explainer">nostrfiles.dev ஃபைலை மாற்றமுடியும்</string>
<string name="upload_server_nostrcheckme">nostrcheck.me - நம்பகமானது</string>
<string name="upload_server_nostrcheckme_explainer">nostrcheck.me ஃபைலை மாற்றமுடியும்</string>
<string name="upload_server_imgur_nip94">சரிபார்க்கக்கூடிய Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Imgur ஃபைலை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_nostrimg_nip94">சரிபார்க்கக்கூடிய NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">NostrImg ஃபைலை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_nostrbuild_nip94">சரிபார்க்கக்கூடிய Nostr.build (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">Nostr.build ஃபைலை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_nostrfilesdev_nip94">சரிபார்க்கக்கூடிய Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">Nostrfiles.dev ஃபைலை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_nostrcheckme_nip94">சரிபார்க்கக்கூடிய Nostrcheck.me (NIP-94)</string>
<string name="upload_server_nostrcheckme_nip94_explainer">Nostrcheck.me ஃபைலை மாற்றியிருக்கிறதா என்று சரிபார்க்கப்படும். புதிய NIP: பிற கிளயண்ட்டுகளில் இதை பார்க்க முடியாமல் இருக்கலாம்.</string>
<string name="upload_server_relays_nip95">உங்கள் ரிலே (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">ஃபைல்ஸ் உங்கள் ரிலேக்களில் ஹோஸ்ட் செய்யப்படும். புதிய NIP: ரிலேயில் இச்சேவை உள்ளதா என சரிபார்க்கவும்</string>
<string name="connect_via_tor_short">Tor/Orbot அமைப்பு முறை</string>
<string name="connect_via_tor">Orbot அமைப்பு மூலம் இணைக்கவும்</string>
<string name="do_you_really_want_to_disable_tor_title">Tor/Orbot இணைப்பை துண்டிக்கவும்?</string>
<string name="do_you_really_want_to_disable_tor_text">உங்கள் டேட்டா உடனே வழக்கமான நெட்வொர்க்கிற்கு மாற்றப்படும்</string>
<string name="yes">ஆம்</string>
<string name="no">இல்லை</string>
<string name="follow_list_selection">பின்பற்றப் படுவோர் பட்டியல்</string>
<string name="follow_list_kind3follows">எல்லா பின்பற்றப் படுவோர்</string>
<string name="follow_list_global">முழுதளாவிய</string>
<string name="connect_through_your_orbot_setup_markdown">
## Tor மூலம் Orbot உடன் இணைக்க
\n\n1. [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) இன்ஸ்டால் செய்க
\n2. Orbot-ஐ தொடங்குங்கள்
\n3. Orbot-இல் Socks போர்ட்டை செக் செய்க. இயல்புநிலையில் அது 9050
\n4. வேண்டுமானால் Orbot-இல் போர்ட்டை மாற்றுங்கள்
\n5. இந்த திரையில் Socks போர்டை கட்டமைக்க
\n6. ஆக்டிவேட் பொத்தானை அழுத்தி Orbot-ஐ proxy-ஆக பயன்படுத்துங்கள்
</string>
<string name="orbot_socks_port">Orbot Socks போர்ட்</string>
<string name="invalid_port_number">செல்லாத போர்ட் நம்பர்</string>
<string name="use_orbot">Orbot-ஐ பயன்படுத்துங்கள்</string>
<string name="disconnect_from_your_orbot_setup">Orbot இணைப்பது துண்டிக்க</string>
<string name="app_notification_dms_channel_name">தனிப்பட்ட செய்திகள்</string>
<string name="app_notification_dms_channel_description">தனிப்பட்ட செய்தி வருகையில் தெரிவிக்கவும்</string>
<string name="app_notification_zaps_channel_name">ஜாப் பெறப்பட்டது</string>
<string name="app_notification_zaps_channel_description">யாரேனும் உங்களுக்கு ஜாப் அனுப்பும்போது தெரிவிக்கவும்</string>
<string name="app_notification_zaps_channel_message">%1$s ஸாட்ஸ்</string>
<string name="app_notification_zaps_channel_message_from">%1$s -இடமிருந்து</string>
<string name="app_notification_zaps_channel_message_for">%1$s -க்கு</string>
<string name="reply_notify">தெரிவிக்க</string>
<string name="channel_list_join_conversation">உரையாடலில் இணைக</string>
<string name="channel_list_user_or_group_id">யூஸர் அல்லது குழுவின் ID</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent அல்லது hex</string>
<string name="channel_list_create_channel">உருவாக்குக</string>
<string name="channel_list_join_channel">இணைக</string>
<string name="today">இன்று</string>
<string name="content_warning">உள்ளடக்க எச்சரிக்கை</string>
<string name="content_warning_explanation">இந்த குறிப்பின் உள்ளடக்கம் சிலருக்கு அவமதிப்பாகவோ அருவெறுப்பாகவோ இருக்கலாம்</string>
<string name="content_warning_hide_all_sensitive_content">எப்போதும் உணர்ச்சிபூர்வமான உள்ளடக்கத்தை மறைக்க</string>
<string name="content_warning_show_all_sensitive_content">எப்போதும் உணர்ச்சிபூர்வமான உள்ளடக்கத்தை காண்பிக்க</string>
<string name="content_warning_see_warnings">எப்போதும் உள்ளடக்க எச்சரிக்கையை காண்பிக்க</string>
<string name="recommended_apps">பரிந்துரைகள்</string>
<string name="filter_spam_from_strangers">தெரியாதவர்களிடமிருந்து ஸ்பேம்-மை வடிகட்டுக</string>
<string name="warn_when_posts_have_reports_from_your_follows">குறிப்புகளில் நீங்கள் பின்பற்றுபவரின் புகார் இருந்தால் எச்சரிக்க</string>
<string name="new_reaction_symbol">புதிய ரியாக்ஷன் குறி</string>
<string name="no_reaction_type_setup_long_press_to_change">எந்த ரியாக்ஷனும் தேர்ந்தெடுக்கவில்லை. மாற்ற நீண்ட நேரம் அழுத்தவும்</string>
<string name="zapraiser">ஜாப்-திரட்டுதல்</string>
<string name="zapraiser_explainer">இந்த குறிப்புடன் திரட்டுவதற்கான ஸாட்ஸ் இலக்குத் தொகை சேர்க்கப்படும். இப்பயன்பாட்டுள்ள கிளயண்ட்டுகள் ஜாப்-களை ஊக்குவிக்க ப்ரோக்ரேஸ் பார் மூலம் காண்பிக்கலாம்</string>
<string name="zapraiser_target_amount_in_sats">ஸாட்ஸ் இலக்குத் தொகை</string>
<string name="sats_to_complete">ஜாப்-திரட்டுதல் %1$s. இலக்கை அடைய %2$s</string>
<string name="read_from_relay">படிப்பதற்கான ரிலே</string>
<string name="write_to_relay">போஸ்ட் செய்வதற்கான ரிலே</string>
<string name="an_error_ocurred_trying_to_get_relay_information">%1$s ரிலே விவரங்கள் பெறுகையில் பிழை</string>
<string name="owner">உரிமையாளர்</string>
<string name="version">பதிப்பு</string>
<string name="software">மென்பொருள்</string>
<string name="contact">தொடர்புக்கு</string>
<string name="supports">ஆதரிக்கப்படும் NIPகள்</string>
<string name="admission_fees">அனுமதி கட்டணம்</string>
<string name="payments_url">கட்டண URL</string>
<string name="limitations">வரம்புகள்</string>
<string name="countries">நாடுகள்</string>
<string name="languages">மொழிகள்</string>
<string name="tags">டாகுகள்</string>
<string name="posting_policy">போஸ்ட் செய்வதற்கான கொள்கை</string>
<string name="message_length">குறிப்பு நீளம்</string>
<string name="subscriptions">சப்ஸ்க்ரிப்ஷன்கள்</string>
<string name="filters">வடிப்பான்கள்</string>
<string name="subscription_id_length">சப்ஸ்க்ரிப்ஷன் ID நீளம்</string>
<string name="minimum_prefix">குறைந்தபட்ச முன்னீடு</string>
<string name="maximum_event_tags">அதிகபட்ச நிகழ்வு டாகுகள்</string>
<string name="content_length">உள்ளடக்க நீளம்</string>
<string name="minimum_pow">குறைந்தபட்ச PoW</string>
<string name="auth">சான்றளிப்பு</string>
<string name="payment">கட்டணம்</string>
<string name="cashu">Cashu டோக்கன்</string>
<string name="cashu_redeem">மீட்க</string>
<string name="live_stream_live_tag">நேரலை</string>
<string name="live_stream_offline_tag">ஆஃப்லைன்</string>
<string name="live_stream_ended_tag">முடிந்தது</string>
<string name="live_stream_planned_tag">வரவிருக்கிறது</string>
<string name="live_stream_is_offline">லைவ்ஸ்ட்ரீம் ஆஃப்லைனில் உள்ளது</string>
<string name="live_stream_has_ended">லைவ்ஸ்ட்ரீம் முடிந்துவிட்டது</string>
<string name="are_you_sure_you_want_to_log_out">வெளியேறு உங்கள் அனைத்து லோக்கல் விவரங்களையும் நீக்கிவிடும். வெளியேறும் முன் உங்கள் அக்கவுண்ட் தொலையாமல் இருக்க ரகசியசாவியை பத்திரமாக பிரதி செய்து சேமிக்கப்பட்டுள்ளதா என்று சரிபார்க்கவும். வெளியேற்றம் தொடர வேண்டுமா?</string>
<string name="followed_tags">பின்பற்றப்படும் டாகுகள்</string>
<string name="relay_setup">ரிலேகள்</string>
</resources>