Compare commits

...

52 Commits

Author SHA1 Message Date
Vitor Pamplona
6fbb59ddac v0.70.2 2023-07-21 10:44:43 -04:00
Vitor Pamplona
fe4a4b6fad Stops Video playback when switching in and out of Tor 2023-07-21 10:44:02 -04:00
Vitor Pamplona
8dd5705f02 Merges VideoCache initialization with get methods 2023-07-21 10:08:04 -04:00
Vitor Pamplona
6723225885 Creates playback managers in a lazy mode, after the initialization of the video caching service. 2023-07-21 09:48:25 -04:00
Vitor Pamplona
fb19454259 Moves the video caching service initialization foreground services. 2023-07-21 09:47:54 -04:00
Vitor Pamplona
c6f63a13d3 Merge pull request #511 from greenart7c3/main
add option to send notes to selected relays
2023-07-21 09:06:44 -04:00
Vitor Pamplona
77057fcac5 Apply hidden to boosted notes only. 2023-07-21 09:05:13 -04:00
Vitor Pamplona
676d5e9b88 Merge pull request #516 from greenart7c3/boosted_blocked
fix boosted notes from blocked users appearing as blank
2023-07-21 08:58:05 -04:00
greenart7c3
d6dda0053a fix boosted notes from blocked users appearing as blank 2023-07-21 05:41:50 -03:00
greenart7c3
d33e62367e Merge branch 'main' into main 2023-07-21 05:24:12 -03:00
Vitor Pamplona
47d7a53c1c Merge pull request #513 from davotoula/update_translations
Updated translations
2023-07-20 20:03:22 -04:00
Vitor Pamplona
6fcf472638 Merge branch 'main' into update_translations 2023-07-20 20:03:17 -04:00
Vitor Pamplona
e2f5a1a0e4 v0.70.1 2023-07-20 19:48:02 -04:00
Vitor Pamplona
8ea25e735d Fixes wakelock permission for the f-droid build 2023-07-20 19:46:55 -04:00
Vitor Pamplona
a9d05070ed Remove restrictions of height in the first and second rows of the note. 2023-07-20 19:35:16 -04:00
Vitor Pamplona
d78ec6b02a Centers the tag list vertically 2023-07-20 19:34:52 -04:00
Vitor Pamplona
8971b4e190 Samsung phones don't seem to have a . after hr and min 2023-07-20 19:34:33 -04:00
Vitor Pamplona
dea0adaea4 Reducing the distance from boost to the play button 2023-07-20 19:34:12 -04:00
Vitor Pamplona
5de4fbcbe7 Fixing the size of the 3 dot menu in post 2023-07-20 19:33:54 -04:00
Vitor Pamplona
2330e086ea v0.70.0 2023-07-20 18:02:01 -04:00
Vitor Pamplona
11d51b3854 Wrong Immutable Class 2023-07-20 18:01:17 -04:00
Vitor Pamplona
008469adbc Background Playback
- Moves Video/Audio player to a foreground service.
- Migrates Feed, Stories and Live Stream screens to that service
- Blocks screen from going to sleep if a video is playing.
- Blocks WIFI from going to sleep if an online video is playing.
- Allows the app to pause while listening to media
- Manages cache for up to 30 videos in parallel for each of the 3 categories: local, streaming, progressive content
- Activates the use of popup with artwork that points to the screen with the video
- Creates a button to allow any video to play while browsing the app/phone
- Moves app to SingleTop mode.
- Keeps viewed position cached for up to 100 videos.
- Restructures the starting screen from App Nativation
2023-07-20 17:59:10 -04:00
David Kaspar
bbe26c7a30 Updated se/de/cs translations
Added missing DE locale config
2023-07-18 20:29:19 +02:00
greenart7c3
c2b9bb413d add long press to show relay info, fix validation, show relay url without prefix 2023-07-17 17:08:14 -03:00
greenart7c3
5c1c4f8db2 Merge branch 'main' of github.com:greenart7c3/amethyst 2023-07-17 15:44:33 -03:00
greenart7c3
b46bb7ecc5 add relay selection to nip 94 and 95 uploads 2023-07-17 15:43:49 -03:00
greenart7c3
2fed65db8d created RelaySelectionDialog file 2023-07-17 15:43:49 -03:00
greenart7c3
66cc4b5526 remove unused import 2023-07-17 15:43:49 -03:00
greenart7c3
91488f0350 add option to send notes to selected relays 2023-07-17 15:43:49 -03:00
Vitor Pamplona
f2429e011e Upgrades the compressor API to the new version 2023-07-17 14:28:52 -04:00
Vitor Pamplona
1374f36b1f Updating libraries 2023-07-17 13:42:17 -04:00
Vitor Pamplona
d185e65b3f Fixes Like icon aligned to the left in the Notifcations screen 2023-07-17 13:42:08 -04:00
Vitor Pamplona
64bf17b1a0 v0.69.3 2023-07-17 12:16:40 -04:00
Vitor Pamplona
93b50468fd Adjusts colors for the NÏP05 to be more subtle. 2023-07-17 12:14:21 -04:00
Vitor Pamplona
65ab756c2b v0.69.2 2023-07-17 11:50:23 -04:00
Vitor Pamplona
e298285145 Migration to Niel Liesmons' icons 2023-07-17 11:48:29 -04:00
greenart7c3
d730eeb9a1 add relay selection to nip 94 and 95 uploads 2023-07-17 11:15:19 -03:00
greenart7c3
0d63441611 created RelaySelectionDialog file 2023-07-17 10:41:47 -03:00
greenart7c3
f0ecf382e0 remove unused import 2023-07-17 09:56:16 -03:00
greenart7c3
d0dee84031 add option to send notes to selected relays 2023-07-17 09:48:34 -03:00
Vitor Pamplona
ad59b691fd v0.69.1 2023-07-16 20:17:53 -04:00
Vitor Pamplona
a256e34878 Fix for mute list not showing 2023-07-16 20:16:17 -04:00
Vitor Pamplona
6ad00c112a v0.69.0 2023-07-16 19:07:28 -04:00
Vitor Pamplona
0010a19c24 Fixes the profile display for new users without pictures. 2023-07-16 18:57:48 -04:00
Vitor Pamplona
d78c7a91cb Lot's of changes from local storage to relay storage.
- Moves Following Communities local DB to Contact List
- Moves Following Channels local DB to Contact List
- Moves Following BlockList local DB to Mute List (Private part)
- Migrates all deprecated local lists to event kinds.
- Views Mute Feed (disables hidden authors for that specific list)
- Breaks Security Filters screen in 2 tabs: Blocked and Spammers (automated filter)
- Restructures PeopleList event kind
- Fixes older channels and communities not loading on the discovery tab
2023-07-16 18:57:28 -04:00
Vitor Pamplona
4a2dced02f Formatting 2023-07-16 11:53:12 -04:00
Vitor Pamplona
2d1a63be57 Looks like relays accept id prefixes of 8 more frequently than 6 2023-07-16 11:50:55 -04:00
Vitor Pamplona
9e207d0f24 Adds parsing support for NNS Events. 2023-07-16 11:50:16 -04:00
Vitor Pamplona
efad94ddfd Moves contact list to closer to metadata in the Local Cache 2023-07-16 11:49:41 -04:00
Vitor Pamplona
d49fde755e BugFix for AppDefinitionEvent updates 2023-07-16 11:49:15 -04:00
Vitor Pamplona
0224af18c0 Forces a given event kind to be displayed in the Discovery tab 2023-07-16 11:47:05 -04:00
Vitor Pamplona
53a4dfb702 Moves video player to the new Exoplayer package 2023-07-16 11:42:55 -04:00
111 changed files with 7629 additions and 1200 deletions

View File

@@ -13,14 +13,14 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 252
versionName "0.68.4"
versionCode 259
versionName "0.70.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
resConfigs("ar", "cs", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW", "ja")
resConfigs("ar", "cs", "de", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW", "ja")
}
buildTypes {
@@ -110,7 +110,7 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
implementation 'net.engawapg.lib:zoomable:1.4.2'
implementation "net.engawapg.lib:zoomable:1.4.3"
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
@@ -139,9 +139,9 @@ dependencies {
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
// view videos
implementation "com.google.android.exoplayer:exoplayer:${exoplayer_version}"
implementation "androidx.media3:media3-exoplayer:${exoplayer_version}"
// important for proxy / tor
implementation "com.google.android.exoplayer:extension-okhttp:${exoplayer_version}"
implementation "androidx.media3:media3-datasource-okhttp:${exoplayer_version}"
// Load images from the web.
implementation "io.coil-kt:coil-compose:$coil_version"
@@ -175,6 +175,9 @@ dependencies {
// Language picker and Theme chooser
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.media3:media3-exoplayer-hls:1.1.0'
implementation 'androidx.media3:media3-ui:1.1.0'
implementation 'androidx.media3:media3-session:1.1.0'
// Local model for language identification
playImplementation 'com.google.mlkit:language-id:17.0.4'
@@ -196,7 +199,7 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
// Video compression lib
implementation 'com.github.AbedElazizShe:LightCompressor:1.2.3'
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.1'
// Image compression lib
implementation 'id.zelory:compressor:3.0.1'

View File

@@ -4007,7 +4007,7 @@ class RichTextParserTest {
"Bech(npub17m7f7q08k4x746s2v45eyvwppck32dcahw7uj2mu5txuswldgqkqw9zms7)"
)
state.paragraphs.flatten().forEachIndexed { index, seg ->
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
Assert.assertEquals(
expectedResult[index],
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
@@ -4027,7 +4027,7 @@ class RichTextParserTest {
Assert.assertTrue(state.imagesForPager.isEmpty())
Assert.assertTrue(state.imageList.isEmpty())
Assert.assertTrue(state.customEmoji.isEmpty())
Assert.assertEquals("Hi, how are you doing? ", state.paragraphs.firstOrNull()?.firstOrNull()?.segmentText)
Assert.assertEquals("Hi, how are you doing? ", state.paragraphs.firstOrNull()?.words?.firstOrNull()?.segmentText)
}
@Test
@@ -4039,7 +4039,7 @@ class RichTextParserTest {
Assert.assertTrue(state.customEmoji.isEmpty())
Assert.assertEquals(
"\nHi, \nhow\n\n\n are you doing? \n",
state.paragraphs.joinToString("\n") { it.joinToString(" ") { it.segmentText } }
state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } }
)
}
@@ -4102,7 +4102,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
"Image(https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg)"
)
state.paragraphs.flatten().forEachIndexed { index, seg ->
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
Assert.assertEquals(
expectedResult[index],
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
@@ -4112,7 +4112,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
private fun printStateForDebug(state: RichTextViewerState) {
state.paragraphs.forEachIndexed { index, paragraph ->
paragraph.forEach { seg ->
paragraph.words.forEach { seg ->
println(
"\"${
seg.javaClass.simpleName.replace(

View File

@@ -27,6 +27,13 @@
<!-- To know receive notifications when the app connects/disconnects from the web -->
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- Audio/Video Playback -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- Keeps screen on while playing videos -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Old permission to access media -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
@@ -51,6 +58,7 @@
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout"
android:theme="@style/Theme.Amethyst">
@@ -91,6 +99,17 @@
android:screenOrientation="fullSensor"
tools:replace="screenOrientation" />
<service
android:name=".PlaybackService"
android:foregroundServiceType="mediaPlayback"
android:stopWithTask="true"
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -0,0 +1,132 @@
package com.vitorpamplona.amethyst
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.util.LruCache
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.Player.STATE_IDLE
import androidx.media3.common.Player.STATE_READY
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import com.vitorpamplona.amethyst.ui.MainActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlin.math.abs
class MultiPlayerPlaybackManager(
private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null,
private val cachedPositions: VideoViewedPositionCache
) {
// protects from LruCache killing playing sessions
private val playingMap = mutableMapOf<String, MediaSession>()
private val cache =
object : LruCache<String, MediaSession>(10) { // up to 10 videos in the screen at the same time
override fun entryRemoved(
evicted: Boolean,
key: String?,
oldValue: MediaSession?,
newValue: MediaSession?
) {
super.entryRemoved(evicted, key, oldValue, newValue)
if (!playingMap.contains(key)) {
oldValue?.let {
it.player.release()
it.release()
}
}
}
}
private fun getCallbackIntent(callbackUri: String, applicationContext: Context): PendingIntent {
return PendingIntent.getActivity(
applicationContext,
0,
Intent(Intent.ACTION_VIEW, callbackUri.toUri(), applicationContext, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun getMediaSession(id: String, uri: String, callbackUri: String?, context: Context, applicationContext: Context): MediaSession {
val existingSession = playingMap.get(id) ?: cache.get(id)
if (existingSession != null) return existingSession
val player = ExoPlayer.Builder(context).run {
dataSourceFactory?.let { setMediaSourceFactory(it) }
build()
}
player.apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
volume = 0f
setWakeMode(C.WAKE_MODE_NETWORK)
}
val mediaSession = MediaSession.Builder(context, player).run {
callbackUri?.let {
setSessionActivity(getCallbackIntent(it, applicationContext))
}
setId(id)
build()
}
player.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) {
playingMap.put(id, mediaSession)
} else {
cache.put(id, mediaSession)
playingMap.remove(id, mediaSession)
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
STATE_IDLE -> {
if (player.currentPosition > 5 * 60) { // 5 seconds
cachedPositions.add(uri, player.currentPosition)
}
}
STATE_READY -> {
cachedPositions.get(uri)?.let { lastPosition ->
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
player.seekTo(lastPosition)
}
}
}
else -> {
if (player.currentPosition > 5 * 60) { // 5 seconds
cachedPositions.add(uri, player.currentPosition)
}
}
}
}
})
cache.put(id, mediaSession)
return mediaSession
}
fun releaseAppPlayers() {
GlobalScope.launch(Dispatchers.Main) {
cache.evictAll()
playingMap.forEach {
it.value.player.release()
it.value.release()
}
playingMap.clear()
}
}
fun playingContent(): Collection<MediaSession> {
return playingMap.values
}
}

View File

@@ -0,0 +1,40 @@
package com.vitorpamplona.amethyst
import android.content.ComponentName
import android.content.Context
import android.os.Bundle
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.MoreExecutors
object PlaybackClientController {
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun prepareController(
controllerID: String,
videoUri: String,
callbackUri: String?,
context: Context,
onReady: (MediaController) -> Unit
) {
// creating a bundle object
// creating a bundle object
val bundle = Bundle()
bundle.putString("id", controllerID)
bundle.putString("uri", videoUri)
bundle.putString("callbackUri", callbackUri)
val sessionTokenLocal = SessionToken(context, ComponentName(context, PlaybackService::class.java))
val controllerFuture = MediaController
.Builder(context, sessionTokenLocal)
.setConnectionHints(bundle)
.buildAsync()
controllerFuture.addListener(
{
val controller = controllerFuture.get()
onReady(controller)
},
MoreExecutors.directExecutor()
)
}
}

View File

@@ -0,0 +1,124 @@
package com.vitorpamplona.amethyst
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.service.HttpClient
@UnstableApi // Extend MediaSessionService
class PlaybackService : MediaSessionService() {
private var videoViewedPositionCache = VideoViewedPositionCache()
private var managerHls: MultiPlayerPlaybackManager? = null
private var managerProgressive: MultiPlayerPlaybackManager? = null
private var managerLocal: MultiPlayerPlaybackManager? = null
fun newHslDataSource(): MediaSource.Factory {
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient()))
}
fun newProgressiveDataSource(): MediaSource.Factory {
return ProgressiveMediaSource.Factory(VideoCache.get(applicationContext, HttpClient.getHttpClient()))
}
// Create your Player and MediaSession in the onCreate lifecycle event
@OptIn(UnstableApi::class)
override fun onCreate() {
super.onCreate()
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
managerLocal = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
// Stop all videos and recreates all managers when the proxy changes.
HttpClient.proxyChangeListeners.add {
val toDestroyHls = managerHls
val toDestroyProgressive = managerProgressive
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
toDestroyHls?.releaseAppPlayers()
toDestroyProgressive?.releaseAppPlayers()
}
setMediaNotificationProvider(
DefaultMediaNotificationProvider.Builder(applicationContext).build()
)
}
override fun onDestroy() {
managerHls?.releaseAppPlayers()
managerLocal?.releaseAppPlayers()
managerProgressive?.releaseAppPlayers()
super.onDestroy()
}
fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? {
return if (fileName.startsWith("file")) {
managerLocal
} else if (fileName.endsWith("m3u8")) {
managerHls
} else {
managerProgressive
}
}
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
// Updates any new player ready
super.onUpdateNotification(session, startInForegroundRequired)
// Overrides the notification with any player actually playing
managerHls?.playingContent()?.forEach {
if (it.player.isPlaying) {
super.onUpdateNotification(it, startInForegroundRequired)
}
}
managerLocal?.playingContent()?.forEach {
if (it.player.isPlaying) {
super.onUpdateNotification(session, startInForegroundRequired)
}
}
managerProgressive?.playingContent()?.forEach {
if (it.player.isPlaying) {
super.onUpdateNotification(session, startInForegroundRequired)
}
}
// Overrides again with playing with audio
managerHls?.playingContent()?.forEach {
if (it.player.isPlaying && it.player.volume > 0) {
super.onUpdateNotification(it, startInForegroundRequired)
}
}
managerLocal?.playingContent()?.forEach {
if (it.player.isPlaying && it.player.volume > 0) {
super.onUpdateNotification(session, startInForegroundRequired)
}
}
managerProgressive?.playingContent()?.forEach {
if (it.player.isPlaying && it.player.volume > 0) {
super.onUpdateNotification(session, startInForegroundRequired)
}
}
}
// Return a MediaSession to link with the MediaController that is making
// this request.
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
val id = controllerInfo.connectionHints.getString("id") ?: return null
val uri = controllerInfo.connectionHints.getString("uri") ?: return null
val callbackUri = controllerInfo.connectionHints.getString("callbackUri")
val manager = getAppropriateMediaSessionManager(uri)
return manager?.getMediaSession(id, uri, callbackUri, context = this, applicationContext = applicationContext)
}
}

View File

@@ -60,9 +60,6 @@ object ServiceManager {
.build()
}
// Initializes video cache.
VideoCache.init(context.applicationContext)
if (myAccount != null) {
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())

View File

@@ -1,14 +1,15 @@
package com.vitorpamplona.amethyst
import android.content.Context
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
import com.google.android.exoplayer2.upstream.cache.SimpleCache
import com.vitorpamplona.amethyst.service.HttpClient
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import okhttp3.OkHttpClient
object VideoCache {
@UnstableApi object VideoCache {
var exoPlayerCacheSize: Long = 90 * 1024 * 1024 // 90MB
@@ -20,33 +21,34 @@ object VideoCache {
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
@Synchronized
fun init(context: Context) {
if (!this::simpleCache.isInitialized) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
private fun init(context: Context, client: OkHttpClient) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
simpleCache = SimpleCache(
context.cacheDir,
leastRecentlyUsedCacheEvictor,
exoDatabaseProvider
)
simpleCache = SimpleCache(
context.cacheDir,
leastRecentlyUsedCacheEvictor,
exoDatabaseProvider
)
cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(
OkHttpDataSource.Factory(HttpClient.getHttpClient())
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
} else {
cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(
OkHttpDataSource.Factory(HttpClient.getHttpClient())
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
renewCacheFactory(client)
}
fun get(): CacheDataSource.Factory {
// This method should be called when proxy setting changes.
fun renewCacheFactory(client: OkHttpClient) {
cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(
OkHttpDataSource.Factory(client)
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
fun get(context: Context, client: OkHttpClient): CacheDataSource.Factory {
if (!this::simpleCache.isInitialized) {
init(context, client)
}
return cacheDataSourceFactory
}
}

View File

@@ -0,0 +1,15 @@
package com.vitorpamplona.amethyst
import android.util.LruCache
class VideoViewedPositionCache {
val cachedPosition = LruCache<String, Long>(100)
fun add(uri: String, position: Long) {
cachedPosition.put(uri, position)
}
fun get(uri: String): Long? {
return cachedPosition.get(uri)
}
}

View File

@@ -6,6 +6,8 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.OptOutFromFilters
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
@@ -18,6 +20,10 @@ import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.Nip47URI
import com.vitorpamplona.amethyst.ui.note.combineWith
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@@ -49,9 +55,11 @@ val KIND3_FOLLOWS = " All Follows "
@Stable
class Account(
val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels,
var followingCommunities: Set<String> = setOf(),
var hiddenUsers: Set<String> = setOf(),
var followingChannels: Set<String> = DefaultChannels, // deprecated
var followingCommunities: Set<String> = setOf(), // deprecated
var hiddenUsers: Set<String> = setOf(), // deprecated
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var languagePreferences: Map<String, String> = mapOf(),
@@ -76,7 +84,7 @@ class Account(
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>(),
var settings: Settings = Settings()
) {
var transientHiddenUsers: Set<String> = setOf()
var transientHiddenUsers: ImmutableSet<String> = persistentSetOf()
// Observers line up here.
val live: AccountLiveData = AccountLiveData(this)
@@ -84,7 +92,24 @@ class Account(
val liveLastRead: AccountLiveData = AccountLiveData(this)
val saveable: AccountLiveData = AccountLiveData(this)
@Immutable
data class LiveHiddenUsers(
val hiddenUsers: ImmutableSet<String>,
val spammers: ImmutableSet<String>,
val showSensitiveContent: Boolean?
)
val liveHiddenUsers: LiveData<LiveHiddenUsers> = live.combineWith(getBlockListNote().live().metadata) { localLive, liveMuteListEvent ->
val liveBlockedUsers = (liveMuteListEvent?.note?.event as? PeopleListEvent)?.publicAndPrivateUsers(loggedIn.privKey)
LiveHiddenUsers(
hiddenUsers = liveBlockedUsers ?: persistentSetOf(),
spammers = localLive?.account?.transientHiddenUsers ?: persistentSetOf(),
showSensitiveContent = showSensitiveContent
)
}.distinctUntilChanged()
var userProfileCache: User? = null
fun updateAutomaticallyStartPlayback(
automaticallyStartPlayback: Boolean?
) {
@@ -114,7 +139,7 @@ class Account(
filterSpamFromStrangers = filterSpam
OptOutFromFilters.start(warnAboutPostsWithReports, filterSpamFromStrangers)
if (!filterSpamFromStrangers) {
transientHiddenUsers = setOf()
transientHiddenUsers = persistentSetOf()
}
live.invalidateData()
saveable.invalidateData()
@@ -128,14 +153,6 @@ class Account(
}
}
fun followingChannels(): List<Channel> {
return followingChannels.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
}
fun followingCommunities(): List<AddressableNote> {
return followingCommunities.mapNotNull { LocalCache.checkGetOrCreateAddressableNote(it) }
}
fun isWriteable(): Boolean {
return loggedIn.privKey != null
}
@@ -144,21 +161,25 @@ class Account(
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
val followsTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
if (contactList != null && follows.isNotEmpty()) {
val event = ContactListEvent.create(
follows,
followsTags,
relays,
loggedIn.privKey!!
if (contactList != null && contactList.tags.isNotEmpty()) {
val event = ContactListEvent.updateRelayList(
earlierVersion = contactList,
relayUse = relays,
privateKey = loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
} else {
val event = ContactListEvent.create(listOf(), listOf(), relays, loggedIn.privKey!!)
val event = ContactListEvent.createFromScratch(
followUsers = listOf(),
followTags = listOf(),
followCommunities = listOf(),
followEvents = DefaultChannels.toList(),
relayUse = relays,
privateKey = loggedIn.privKey!!
)
// Keep this local to avoid erasing a good contact list.
// Client.send(event)
@@ -398,27 +419,90 @@ class Account(
}
}
private fun migrateCommunitiesAndChannelsIfNeeded(latestContactList: ContactListEvent?): ContactListEvent? {
if (latestContactList == null) return latestContactList
var returningContactList: ContactListEvent = latestContactList
if (followingCommunities.isNotEmpty()) {
followingCommunities.forEach {
ATag.parse(it, null)?.let {
returningContactList = ContactListEvent.followAddressableEvent(returningContactList, it, loggedIn.privKey!!)
}
}
followingCommunities = emptySet()
}
if (followingChannels.isNotEmpty()) {
followingChannels.forEach {
returningContactList = ContactListEvent.followEvent(returningContactList, it, loggedIn.privKey!!)
}
followingChannels = emptySet()
}
return returningContactList
}
fun follow(user: User) {
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList)
val event = if (contactList != null) {
ContactListEvent.create(
followingUsers.plus(Contact(user.pubkeyHex, null)),
followingTags,
contactList.relays(),
loggedIn.privKey!!
ContactListEvent.followUser(contactList, user.pubkeyHex, loggedIn.privKey!!)
} else {
ContactListEvent.createFromScratch(
followUsers = listOf(Contact(user.pubkeyHex, null)),
followTags = emptyList(),
followCommunities = emptyList(),
followEvents = DefaultChannels.toList(),
relayUse = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) },
privateKey = loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
}
fun follow(channel: Channel) {
if (!isWriteable()) return
val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList)
val event = if (contactList != null) {
ContactListEvent.followEvent(contactList, channel.idHex, loggedIn.privKey!!)
} else {
ContactListEvent.createFromScratch(
followUsers = emptyList(),
followTags = emptyList(),
followCommunities = emptyList(),
followEvents = DefaultChannels.toList().plus(channel.idHex),
relayUse = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) },
privateKey = loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
}
fun follow(community: AddressableNote) {
if (!isWriteable()) return
val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList)
val event = if (contactList != null) {
ContactListEvent.followAddressableEvent(contactList, community.address, loggedIn.privKey!!)
} else {
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
ContactListEvent.create(
listOf(Contact(user.pubkeyHex, null)),
followingTags,
relays,
loggedIn.privKey!!
ContactListEvent.createFromScratch(
followUsers = emptyList(),
followTags = emptyList(),
followCommunities = listOf(community.address),
followEvents = DefaultChannels.toList(),
relayUse = relays,
privateKey = loggedIn.privKey!!
)
}
@@ -429,24 +513,22 @@ class Account(
fun follow(tag: String) {
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList)
val event = if (contactList != null) {
ContactListEvent.create(
followingUsers,
followingTags.plus(tag),
contactList.relays(),
ContactListEvent.followHashtag(
contactList,
tag,
loggedIn.privKey!!
)
} else {
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
ContactListEvent.create(
followingUsers,
followingTags.plus(tag),
relays,
loggedIn.privKey!!
ContactListEvent.createFromScratch(
followUsers = emptyList(),
followTags = listOf(tag),
followCommunities = emptyList(),
followEvents = DefaultChannels.toList(),
relayUse = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) },
privateKey = loggedIn.privKey!!
)
}
@@ -457,15 +539,12 @@ class Account(
fun unfollow(user: User) {
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList)
if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
val event = ContactListEvent.create(
followingUsers.filter { it.pubKeyHex != user.pubkeyHex },
followingTags,
contactList.relays(),
if (contactList != null && contactList.tags.isNotEmpty()) {
val event = ContactListEvent.unfollowUser(
contactList,
user.pubkeyHex,
loggedIn.privKey!!
)
@@ -477,15 +556,46 @@ class Account(
fun unfollow(tag: String) {
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList)
if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
val event = ContactListEvent.create(
followingUsers,
followingTags.filter { !it.equals(tag, ignoreCase = true) },
contactList.relays(),
if (contactList != null && contactList.tags.isNotEmpty()) {
val event = ContactListEvent.unfollowHashtag(
contactList,
tag,
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
}
fun unfollow(channel: Channel) {
if (!isWriteable()) return
val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList)
if (contactList != null && contactList.tags.isNotEmpty()) {
val event = ContactListEvent.unfollowEvent(
contactList,
channel.idHex,
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
}
fun unfollow(community: AddressableNote) {
if (!isWriteable()) return
val contactList = migrateCommunitiesAndChannelsIfNeeded(userProfile().latestContactList)
if (contactList != null && contactList.tags.isNotEmpty()) {
val event = ContactListEvent.unfollowAddressableEvent(
contactList,
community.address,
loggedIn.privKey!!
)
@@ -518,19 +628,19 @@ class Account(
return Pair(data, signedEvent)
}
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? {
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List<Relay>? = null): Note? {
if (!isWriteable()) return null
Client.send(data)
Client.send(data, relayList = relayList)
LocalCache.consume(data, null)
Client.send(signedEvent)
Client.send(signedEvent, relayList = relayList)
LocalCache.consume(signedEvent, null)
return LocalCache.notes[signedEvent.id]
}
fun sendHeader(headerInfo: FileHeader): Note? {
fun sendHeader(headerInfo: FileHeader, relayList: List<Relay>? = null): Note? {
if (!isWriteable()) return null
val signedEvent = FileHeaderEvent.create(
@@ -545,7 +655,7 @@ class Account(
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
Client.send(signedEvent, relayList = relayList)
LocalCache.consume(signedEvent, null)
return LocalCache.notes[signedEvent.id]
@@ -561,7 +671,8 @@ class Account(
zapRaiserAmount: Long? = null,
replyingTo: String?,
root: String?,
directMentions: Set<HexKey>
directMentions: Set<HexKey>,
relayList: List<Relay>? = null
) {
if (!isWriteable()) return
@@ -584,7 +695,7 @@ class Account(
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
Client.send(signedEvent, relayList = relayList)
LocalCache.consume(signedEvent)
}
@@ -599,7 +710,8 @@ class Account(
closedAt: Int?,
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null
zapRaiserAmount: Long? = null,
relayList: List<Relay>? = null
) {
if (!isWriteable()) return
@@ -623,7 +735,7 @@ class Account(
zapRaiserAmount = zapRaiserAmount
)
// println("Sending new PollNoteEvent: %s".format(signedEvent.toJson()))
Client.send(signedEvent)
Client.send(signedEvent, relayList = relayList)
LocalCache.consume(signedEvent)
}
@@ -708,7 +820,9 @@ class Account(
Client.send(event)
LocalCache.consume(event)
joinChannel(event.id)
LocalCache.getChannelIfExists(event.id)?.let {
follow(it)
}
}
fun removeEmojiPack(usersEmojiList: Note, emojiList: Note) {
@@ -932,43 +1046,70 @@ class Account(
}
}
fun joinChannel(idHex: String) {
followingChannels = followingChannels + idHex
live.invalidateData()
saveable.invalidateData()
fun getBlockListNote(): AddressableNote {
val aTag = ATag(PeopleListEvent.kind, userProfile().pubkeyHex, PeopleListEvent.blockList, null)
return LocalCache.getOrCreateAddressableNote(aTag)
}
fun leaveChannel(idHex: String) {
followingChannels = followingChannels - idHex
live.invalidateData()
saveable.invalidateData()
fun getBlockList(): PeopleListEvent? {
return getBlockListNote().event as? PeopleListEvent
}
fun joinCommunity(idHex: String) {
followingCommunities = followingCommunities + idHex
live.invalidateData()
private fun migrateHiddenUsersIfNeeded(latestList: PeopleListEvent?): PeopleListEvent? {
if (latestList == null) return latestList
saveable.invalidateData()
}
var returningList: PeopleListEvent = latestList
fun leaveCommunity(idHex: String) {
followingCommunities = followingCommunities - idHex
live.invalidateData()
if (hiddenUsers.isNotEmpty()) {
returningList = PeopleListEvent.addUsers(returningList, hiddenUsers.toList(), true, loggedIn.privKey!!)
hiddenUsers = emptySet()
}
saveable.invalidateData()
return returningList
}
fun hideUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers + pubkeyHex
val blockList = migrateHiddenUsersIfNeeded(getBlockList())
val event = if (blockList != null) {
PeopleListEvent.addUser(
earlierVersion = blockList,
pubKeyHex = pubkeyHex,
isPrivate = true,
privateKey = loggedIn.privKey!!
)
} else {
PeopleListEvent.createListWithUser(
name = PeopleListEvent.blockList,
pubKeyHex = pubkeyHex,
isPrivate = true,
privateKey = loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
live.invalidateData()
saveable.invalidateData()
}
fun showUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers - pubkeyHex
transientHiddenUsers = transientHiddenUsers - pubkeyHex
val blockList = migrateHiddenUsersIfNeeded(getBlockList())
if (blockList != null) {
val event = PeopleListEvent.removeUser(
earlierVersion = blockList,
pubKeyHex = pubkeyHex,
isPrivate = true,
privateKey = loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
transientHiddenUsers = (transientHiddenUsers - pubkeyHex).toImmutableSet()
live.invalidateData()
saveable.invalidateData()
}
@@ -1055,7 +1196,53 @@ class Account(
if (listName == GLOBAL_FOLLOWS) return null
if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingTagSet()
return emptySet()
val privKey = loggedIn.privKey
return if (listName != null) {
val aTag = ATag(PeopleListEvent.kind, userProfile().pubkeyHex, listName, null).toTag()
val list = LocalCache.addressables[aTag]
if (list != null) {
val publicAddresses = list.event?.hashtags() ?: emptySet()
val privateAddresses = privKey?.let {
(list.event as? GeneralListEvent)?.privateHashtags(it)
} ?: emptySet()
(publicAddresses + privateAddresses).toSet()
} else {
emptySet()
}
} else {
emptySet()
}
}
fun selectedCommunitiesFollowList(listName: String?): Set<String>? {
if (listName == GLOBAL_FOLLOWS) return null
if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingCommunitiesSet()
val privKey = loggedIn.privKey
return if (listName != null) {
val aTag = ATag(PeopleListEvent.kind, userProfile().pubkeyHex, listName, null).toTag()
val list = LocalCache.addressables[aTag]
if (list != null) {
val publicAddresses = list.event?.taggedAddresses()?.map { it.toTag() } ?: emptySet()
val privateAddresses = privKey?.let {
(list.event as? GeneralListEvent)?.privateTaggedAddresses(it)?.map { it.toTag() }
} ?: emptySet()
(publicAddresses + privateAddresses).toSet()
} else {
emptySet()
}
} else {
emptySet()
}
}
fun selectedChatsFollowList(): Set<String> {
val contactList = userProfile().latestContactList
return contactList?.taggedEvents()?.toSet() ?: DefaultChannels
}
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
@@ -1076,7 +1263,7 @@ class Account(
Client.send(event)
LocalCache.consume(event)
joinChannel(event.id)
follow(channel)
}
fun decryptContent(note: Note): String? {
@@ -1224,7 +1411,11 @@ class Account(
}
fun isHidden(user: User) = isHidden(user.pubkeyHex)
fun isHidden(userHex: String) = userHex in hiddenUsers || userHex in transientHiddenUsers
fun isHidden(userHex: String): Boolean {
val blockList = getBlockList()
return (blockList?.publicAndPrivateUsers(loggedIn.privKey)?.contains(userHex) ?: false) || userHex in transientHiddenUsers
}
fun followingKeySet(): Set<HexKey> {
return userProfile().cachedFollowingKeySet()
@@ -1343,7 +1534,7 @@ class Account(
it.cache.spamMessages.snapshot().values.forEach {
if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) {
if (it.pubkeyHex != userProfile().pubkeyHex && it.pubkeyHex !in followingKeySet()) {
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
transientHiddenUsers = (transientHiddenUsers + it.pubkeyHex).toImmutableSet()
live.invalidateData()
}
}
@@ -1356,6 +1547,7 @@ class Account(
Log.d("Init", "Account")
backupContactList?.let {
println("Loading saved contacts ${it.toJson()}")
if (userProfile().latestContactList == null) {
LocalCache.consume(it)
}

View File

@@ -95,6 +95,10 @@ abstract class Channel(val idHex: String) {
return null
}
open fun creatorName(): String? {
return creator?.toBestDisplayName()
}
open fun profilePicture(): String? {
return creator?.profilePicture()
}

View File

@@ -33,7 +33,7 @@ object LocalCache {
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
fun checkGetOrCreateUser(key: String): User? {
checkNotInMainThread()
// checkNotInMainThread()
if (isValidHex(key)) {
return getOrCreateUser(key)
@@ -143,7 +143,7 @@ object LocalCache {
}
fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote {
checkNotInMainThread()
// checkNotInMainThread()
// we can't use naddr here because naddr might include relay info and
// the preferred relay should not be part of the index.
@@ -176,6 +176,16 @@ object LocalCache {
}
}
fun consume(event: ContactListEvent) {
val user = getOrCreateUser(event.pubKey)
// avoids processing empty contact lists.
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) {
user.updateContactList(event)
// Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
}
}
fun consume(event: BookmarkListEvent) {
val user = getOrCreateUser(event.pubKey)
if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) {
@@ -535,6 +545,26 @@ object LocalCache {
refreshObservers(note)
}
private fun comsume(event: NNSEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun consume(event: AppDefinitionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -546,7 +576,7 @@ object LocalCache {
}
// Already processed this event.
if (note.event != null) return
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
@@ -580,16 +610,6 @@ object LocalCache {
// // Log.d("RR", event.toJson())
}
fun consume(event: ContactListEvent) {
val user = getOrCreateUser(event.pubKey)
// avoids processing empty contact lists.
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) {
user.updateContactList(event)
// Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
}
}
fun consume(event: PrivateDmEvent, relay: Relay?): Note {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -1335,6 +1355,7 @@ object LocalCache {
is LnZapPaymentResponseEvent -> consume(event)
is LongTextNoteEvent -> consume(event, relay)
is MetadataEvent -> consume(event)
is NNSEvent -> comsume(event)
is PrivateDmEvent -> consume(event, relay)
is PinListEvent -> consume(event)
is PeopleListEvent -> consume(event)

View File

@@ -75,6 +75,10 @@ open class Note(val idHex: String) {
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
}
fun toNostrUri(): String {
return "nostr:${toNEvent()}"
}
open fun idDisplayNote() = idNote().toShortenHex()
fun channelHex(): HexKey? {
@@ -563,6 +567,22 @@ open class Note(val idHex: String) {
liveSet = null
}
}
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
if (event == null) return false
val isBoostedNoteHidden = if (event is GenericRepostEvent || event is RepostEvent || event is CommunityPostApprovalEvent) {
replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false
} else {
false
}
val isSensitive = event?.isSensitive() ?: false
return isBoostedNoteHidden ||
accountChoices.hiddenUsers.contains(author?.pubkeyHex) ||
accountChoices.spammers.contains(author?.pubkeyHex) ||
(isSensitive && accountChoices.showSensitiveContent == false)
}
}
class NoteLiveSet(u: Note) {

View File

@@ -51,6 +51,8 @@ class User(val pubkeyHex: String) {
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
fun toNostrUri() = "nostr:${pubkeyNpub()}"
override fun toString(): String = pubkeyHex
fun toBestDisplayName(): String {
@@ -275,6 +277,10 @@ class User(val pubkeyHex: String) {
return latestContactList?.verifiedFollowTagSet ?: emptySet()
}
fun cachedFollowingCommunitiesSet(): Set<HexKey> {
return latestContactList?.verifiedFollowCommunitySet ?: emptySet()
}
fun cachedFollowCount(): Int? {
return latestContactList?.verifiedFollowKeySet?.size
}

View File

@@ -5,19 +5,29 @@ import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
import java.time.Duration
import kotlin.properties.Delegates
object HttpClient {
private var proxy: Proxy? = null
var proxyChangeListeners = ArrayList<() -> Unit>()
// fires off every time value of the property changes
private var internalProxy: Proxy? by Delegates.observable(null) { _, oldValue, newValue ->
if (oldValue != newValue) {
proxyChangeListeners.forEach {
it()
}
}
}
fun start(account: Account?) {
this.proxy = account?.proxy
this.internalProxy = account?.proxy
}
fun getHttpClient(): OkHttpClient {
val seconds = if (proxy != null) 20L else 10L
val seconds = if (internalProxy != null) 20L else 10L
val duration = Duration.ofSeconds(seconds)
return OkHttpClient.Builder()
.proxy(proxy)
.proxy(internalProxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
@@ -25,7 +35,7 @@ object HttpClient {
}
fun getProxy(): Proxy? {
return proxy
return internalProxy
}
fun initProxy(useProxy: Boolean, hostname: String, port: Int): Proxy? {

View File

@@ -44,17 +44,23 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
)
)
fun createMyChannelsFilter() = TypedFilter(
types = COMMON_FEED_TYPES, // Metadata comes from any relay
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = account.followingChannels.toList(),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
)
)
fun createMyChannelsFilter(): TypedFilter {
val followingEvents = account.selectedChatsFollowList()
fun createLastChannelInfoFilter(): List<TypedFilter> {
return account.followingChannels.map {
return TypedFilter(
types = COMMON_FEED_TYPES, // Metadata comes from any relay
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = followingEvents.toList(),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
)
)
}
fun createLastChannelInfoFilter(): List<TypedFilter>? {
val followingEvents = account.selectedChatsFollowList()
return followingEvents.map {
TypedFilter(
types = COMMON_FEED_TYPES, // Metadata comes from any relay
filter = JsonFilter(
@@ -66,8 +72,10 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
}
}
fun createLastMessageOfEachChannelFilter(): List<TypedFilter> {
return account.followingChannels.map {
fun createLastMessageOfEachChannelFilter(): List<TypedFilter>? {
val followingEvents = account.selectedChatsFollowList()
return followingEvents.map {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(

View File

@@ -22,7 +22,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
it.substring(0, 8)
}
return TypedFilter(
@@ -40,7 +40,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
it.substring(0, 8)
}
return TypedFilter(
@@ -58,7 +58,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
it.substring(0, 8)
}
return TypedFilter(

View File

@@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
@@ -103,11 +104,37 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
)
}
fun createFollowCommunitiesFilter(): TypedFilter? {
val communitiesToLoad = account.selectedCommunitiesFollowList(account.defaultHomeFollowList) ?: emptySet()
if (communitiesToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
LongTextNoteEvent.kind,
ClassifiedsEvent.kind,
HighlightEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind,
CommunityPostApprovalEvent.kind
),
tags = mapOf(
"a" to communitiesToLoad.toList()
),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
)
)
}
val followAccountChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList, relayUrl, time)
}
override fun updateChannelFilters() {
followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowTagsFilter()).ifEmpty { null }
followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowCommunitiesFilter(), createFollowTagsFilter()).ifEmpty { null }
}
}

View File

@@ -39,6 +39,10 @@ class ContactListEvent(
unverifiedFollowTagSet().map { it.lowercase() }.toSet()
}
val verifiedFollowCommunitySet: Set<String> by lazy {
unverifiedFollowAddressSet().toSet()
}
val verifiedFollowKeySetAndMe: Set<HexKey> by lazy {
verifiedFollowKeySet + pubKey
}
@@ -46,6 +50,8 @@ class ContactListEvent(
fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) }
fun unverifiedFollowTagSet() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(1) }
fun unverifiedFollowAddressSet() = tags.filter { it[0] == "a" }.mapNotNull { it.getOrNull(1) }
fun follows() = tags.filter { it[0] == "p" }.mapNotNull {
try {
Contact(decodePublicKey(it[1]).toHexKey(), it.getOrNull(2))
@@ -73,14 +79,22 @@ class ContactListEvent(
companion object {
const val kind = 3
fun create(follows: List<Contact>, followTags: List<String>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
fun createFromScratch(
followUsers: List<Contact>,
followTags: List<String>,
followCommunities: List<ATag>,
followEvents: List<String>,
relayUse: Map<String, ReadWrite>?,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): ContactListEvent {
val content = if (relayUse != null) {
gson.toJson(relayUse)
} else {
""
}
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = follows.map {
val tags = followUsers.map {
if (it.relayUri != null) {
listOf("p", it.pubKeyHex, it.relayUri)
} else {
@@ -88,8 +102,129 @@ class ContactListEvent(
}
} + followTags.map {
listOf("t", it)
} + followEvents.map {
listOf("e", it)
} + followCommunities.map {
if (it.relay != null) {
listOf("a", it.toTag(), it.relay)
} else {
listOf("a", it.toTag())
}
}
return create(
content = content,
tags = tags,
privateKey = privateKey,
createdAt = createdAt
)
}
fun followUser(earlierVersion: ContactListEvent, pubKeyHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (earlierVersion.isTaggedUser(pubKeyHex)) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(element = listOf("p", pubKeyHex)),
privateKey = privateKey,
createdAt = createdAt
)
}
fun unfollowUser(earlierVersion: ContactListEvent, pubKeyHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (!earlierVersion.isTaggedUser(pubKeyHex)) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex },
privateKey = privateKey,
createdAt = createdAt
)
}
fun followHashtag(earlierVersion: ContactListEvent, hashtag: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (earlierVersion.isTaggedHash(hashtag)) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(element = listOf("t", hashtag)),
privateKey = privateKey,
createdAt = createdAt
)
}
fun unfollowHashtag(earlierVersion: ContactListEvent, hashtag: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (!earlierVersion.isTaggedHash(hashtag)) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != hashtag },
privateKey = privateKey,
createdAt = createdAt
)
}
fun followEvent(earlierVersion: ContactListEvent, idHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (earlierVersion.isTaggedEvent(idHex)) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(element = listOf("e", idHex)),
privateKey = privateKey,
createdAt = createdAt
)
}
fun unfollowEvent(earlierVersion: ContactListEvent, idHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (!earlierVersion.isTaggedEvent(idHex)) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != idHex },
privateKey = privateKey,
createdAt = createdAt
)
}
fun followAddressableEvent(earlierVersion: ContactListEvent, aTag: ATag, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (earlierVersion.isTaggedAddressableNote(aTag.toTag())) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(element = listOfNotNull("a", aTag.toTag(), aTag.relay)),
privateKey = privateKey,
createdAt = createdAt
)
}
fun unfollowAddressableEvent(earlierVersion: ContactListEvent, aTag: ATag, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
if (!earlierVersion.isTaggedAddressableNote(aTag.toTag())) return earlierVersion
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != aTag.toTag() },
privateKey = privateKey,
createdAt = createdAt
)
}
fun updateRelayList(earlierVersion: ContactListEvent, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
val content = if (relayUse != null) {
gson.toJson(relayUse)
} else {
""
}
return create(
content = content,
tags = earlierVersion.tags,
privateKey = privateKey,
createdAt = createdAt
)
}
fun create(content: String, tags: List<List<String>>, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ContactListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())

View File

@@ -7,6 +7,7 @@ import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.nip19.Nip19
import fr.acinq.secp256k1.Hex
import fr.acinq.secp256k1.Secp256k1
import nostr.postr.Utils
@@ -44,10 +45,10 @@ open class Event(
fun hasAnyTaggedUser() = tags.any { it.size > 1 && it[0] == "p" }
fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
override fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
override fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
override fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
override fun taggedEmojis() = tags.filter { it.size > 2 && it[0] == "emoji" }.map { EmojiUrl(it[1], it[2]) }
@@ -63,7 +64,7 @@ open class Event(
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
override fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
val aTagValue = it[1]
val relay = it.getOrNull(2)
@@ -76,8 +77,12 @@ open class Event(
override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex }
override fun isTaggedEvent(idHex: String) = tags.any { it.size > 1 && it[0] == "e" && it[1] == idHex }
override fun isTaggedAddressableNote(idHex: String) = tags.any { it.size > 1 && it[0] == "a" && it[1] == idHex }
override fun isTaggedAddressableNotes(idHexes: Set<String>) = tags.any { it.size > 1 && it[0] == "a" && it[1] in idHexes }
override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1)
@@ -128,6 +133,18 @@ open class Event(
}
}
open fun toNIP19(): String {
return if (this is AddressableEvent) {
ATag(kind, pubKey, dTag(), null).toNAddr()
} else {
Nip19.createNEvent(id, pubKey, kind, null)
}
}
fun toNostrUri(): String {
return "nostr:${toNIP19()}"
}
/**
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
*/
@@ -288,6 +305,7 @@ open class Event(
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
NNSEvent.kind -> NNSEvent(id, pubKey, createdAt, tags, content, sig)
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
PinListEvent.kind -> PinListEvent(id, pubKey, createdAt, tags, content, sig)
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)

View File

@@ -27,7 +27,11 @@ interface EventInterface {
fun hasValidSignature(): Boolean
fun isTaggedUser(idHex: String): Boolean
fun isTaggedEvent(idHex: String): Boolean
fun isTaggedAddressableNote(idHex: String): Boolean
fun isTaggedAddressableNotes(idHexes: Set<String>): Boolean
fun isTaggedHash(hashtag: String): Boolean
fun isTaggedHashes(hashtags: Set<String>): Boolean
@@ -46,6 +50,11 @@ interface EventInterface {
fun isSensitive(): Boolean
fun zapraiserAmount(): Long?
fun taggedAddresses(): List<ATag>
fun taggedUsers(): List<HexKey>
fun taggedEvents(): List<HexKey>
fun taggedUrls(): List<String>
fun taggedEmojis(): List<EmojiUrl>
fun matchTag1With(text: String): Boolean
}

View File

@@ -54,7 +54,13 @@ abstract class GeneralListEvent(
return privateTagsCache
}
fun privateTagsOrEmpty(privKey: ByteArray): List<List<String>> {
return privateTags(privKey) ?: emptyList()
}
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] }
fun privateHashtags(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "t" }?.map { it[1] }
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "e" }?.map { it[1] }
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
val aTagValue = it.getOrNull(1)
@@ -90,5 +96,16 @@ abstract class GeneralListEvent(
pubKey
)
}
fun encryptTags(
privateTags: List<List<String>>? = null,
privateKey: ByteArray
): String {
return Utils.encrypt(
msg = gson.toJson(privateTags),
privateKey = privateKey,
pubKey = Utils.pubkeyCreate(privateKey)
)
}
}
}

View File

@@ -14,8 +14,6 @@ interface LnZapEventInterface : EventInterface {
fun zappedRequestAuthor(): String?
fun taggedAddresses(): List<ATag>
fun amount(): BigDecimal?
fun containedPost(): Event?

View File

@@ -0,0 +1,40 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@Immutable
class NNSEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun ip4() = tags.firstOrNull { it.size > 1 && it[0] == "ip4" }?.get(1)
fun ip6() = tags.firstOrNull { it.size > 1 && it[0] == "ip6" }?.get(1)
fun version() = tags.firstOrNull { it.size > 1 && it[0] == "version" }?.get(1)
companion object {
const val kind = 30053
fun create(
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): NNSEvent {
val tags = mutableListOf<List<String>>()
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return NNSEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}

View File

@@ -4,6 +4,9 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet
import nostr.postr.Utils
@Immutable
@@ -15,42 +18,133 @@ class PeopleListEvent(
content: String,
sig: HexKey
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
var publicAndPrivateUserCache: ImmutableSet<HexKey>? = null
fun publicAndPrivateUsers(privateKey: ByteArray?): ImmutableSet<HexKey> {
publicAndPrivateUserCache?.let {
return it
}
val privateUserList = privateKey?.let {
privateTagsOrEmpty(privKey = it).filter { it.size > 1 && it[0] == "p" }.map { it[1] }.toSet()
} ?: emptySet()
val publicUserList = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }.toSet()
publicAndPrivateUserCache = (privateUserList + publicUserList).toImmutableSet()
return publicAndPrivateUserCache ?: persistentSetOf()
}
fun isTaggedUser(idHex: String, isPrivate: Boolean, privateKey: ByteArray): Boolean {
return if (isPrivate) {
privateTagsOrEmpty(privKey = privateKey).any { it.size > 1 && it[0] == "p" && it[1] == idHex }
} else {
isTaggedUser(idHex)
}
}
companion object {
const val kind = 30000
const val blockList = "mute"
fun create(
name: String = "",
events: List<String>? = null,
users: List<String>? = null,
addresses: List<ATag>? = null,
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): PeopleListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
val tags = mutableListOf<List<String>>()
tags.add(listOf("d", name))
events?.forEach {
tags.add(listOf("e", it))
}
users?.forEach {
tags.add(listOf("p", it))
}
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
fun createListWithUser(name: String, pubKeyHex: String, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent {
return if (isPrivate) {
create(
content = encryptTags(listOf(listOf("p", pubKeyHex)), privateKey),
tags = listOf(listOf("d", name)),
privateKey = privateKey,
createdAt = createdAt
)
} else {
create(
content = "",
tags = listOf(listOf("d", name), listOf("p", pubKeyHex)),
privateKey = privateKey,
createdAt = createdAt
)
}
}
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
fun addUsers(earlierVersion: PeopleListEvent, listPubKeyHex: List<String>, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent {
return if (isPrivate) {
create(
content = encryptTags(
privateTags = earlierVersion.privateTagsOrEmpty(privKey = privateKey).plus(
listPubKeyHex.map {
listOf("p", it)
}
),
privateKey = privateKey
),
tags = earlierVersion.tags,
privateKey = privateKey,
createdAt = createdAt
)
} else {
create(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(
listPubKeyHex.map {
listOf("p", it)
}
),
privateKey = privateKey,
createdAt = createdAt
)
}
}
fun addUser(earlierVersion: PeopleListEvent, pubKeyHex: String, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent {
if (earlierVersion.isTaggedUser(pubKeyHex, isPrivate, privateKey)) return earlierVersion
return if (isPrivate) {
create(
content = encryptTags(
privateTags = earlierVersion.privateTagsOrEmpty(privKey = privateKey).plus(element = listOf("p", pubKeyHex)),
privateKey = privateKey
),
tags = earlierVersion.tags,
privateKey = privateKey,
createdAt = createdAt
)
} else {
create(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(element = listOf("p", pubKeyHex)),
privateKey = privateKey,
createdAt = createdAt
)
}
}
fun removeUser(earlierVersion: PeopleListEvent, pubKeyHex: String, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent {
if (!earlierVersion.isTaggedUser(pubKeyHex, isPrivate, privateKey)) return earlierVersion
return if (isPrivate) {
create(
content = encryptTags(
privateTags = earlierVersion.privateTagsOrEmpty(privKey = privateKey).filter { it.size > 1 && it[1] != pubKeyHex },
privateKey = privateKey
),
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex },
privateKey = privateKey,
createdAt = createdAt
)
} else {
create(
content = earlierVersion.content,
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex },
privateKey = privateKey,
createdAt = createdAt
)
}
}
fun create(content: String, tags: List<List<String>>, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return PeopleListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
return PeopleListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -73,10 +73,18 @@ object Client : RelayPool.Listener {
RelayPool.sendFilterOnlyIfDisconnected()
}
fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set<FeedType>? = null, onDone: (() -> Unit)? = null) {
fun send(
signedEvent: EventInterface,
relay: String? = null,
feedTypes: Set<FeedType>? = null,
relayList: List<Relay>? = null,
onDone: (() -> Unit)? = null
) {
checkNotInMainThread()
if (relay == null) {
if (relayList != null) {
RelayPool.sendToSelectedRelays(relayList, signedEvent)
} else if (relay == null) {
RelayPool.send(signedEvent)
} else {
val useConnectedRelayIfPresent = RelayPool.getRelays(relay)

View File

@@ -62,6 +62,12 @@ object RelayPool : Relay.Listener {
relays.forEach { it.sendFilterOnlyIfDisconnected() }
}
fun sendToSelectedRelays(list: List<Relay>, signedEvent: EventInterface) {
list.forEach { relay ->
relays.filter { it.url == relay.url }.forEach { it.send(signedEvent) }
}
}
fun send(signedEvent: EventInterface) {
relays.forEach { it.send(signedEvent) }
}

View File

@@ -6,18 +6,28 @@ import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
@@ -25,13 +35,17 @@ import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
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.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.debugState
import com.vitorpamplona.amethyst.ui.navigation.getRouteWithArguments
import com.vitorpamplona.amethyst.ui.note.Nip47
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
@@ -45,11 +59,13 @@ import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class MainActivity : AppCompatActivity() {
lateinit var navController: NavHostController
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val uri = intent?.data?.toString()
val startingPage = uriToRoute(uri)
LocalPreferences.migrateSingleUserPrefs()
@@ -58,8 +74,11 @@ class MainActivity : AppCompatActivity() {
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(language)
AppCompatDelegate.setApplicationLocales(appLocale)
}
setContent {
navController = rememberNavController()
val themeViewModel: ThemeViewModel = viewModel()
themeViewModel.onChange(LocalPreferences.getTheme())
AmethystTheme(themeViewModel) {
// A surface container using the 'background' color from the theme
@@ -68,9 +87,17 @@ class MainActivity : AppCompatActivity() {
AccountStateViewModel(this@MainActivity)
}
AccountScreen(accountStateViewModel, themeViewModel, startingPage)
AccountScreen(accountStateViewModel, themeViewModel, navController)
}
}
var actionableNextPage by remember { mutableStateOf(startingPage) }
actionableNextPage?.let {
LaunchedEffect(it) {
navController.navigate(it)
}
actionableNextPage = null
}
}
val networkRequest = NetworkRequest.Builder()
@@ -88,6 +115,7 @@ class MainActivity : AppCompatActivity() {
@OptIn(DelicateCoroutinesApi::class)
override fun onResume() {
super.onResume()
// starts muted every time
DefaultMutedSetting.value = true
@@ -109,6 +137,11 @@ class MainActivity : AppCompatActivity() {
super.onPause()
}
override fun onDestroy() {
super.onDestroy()
keepPlayingMutex?.stop()
}
/**
* Release memory when the UI becomes hidden or when system resources become low.
* @param level the memory-related event that was raised.
@@ -121,6 +154,39 @@ class MainActivity : AppCompatActivity() {
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
val uri = intent?.data?.toString()
val startingPage = uriToRoute(uri)
startingPage?.let { route ->
val currentRoute = getRouteWithArguments(navController)
if (!isSameRoute(currentRoute, route)) {
navController.navigate(route) {
popUpTo(Route.Home.route)
launchSingleTop = true
}
}
}
}
private fun isSameRoute(currentRoute: String?, newRoute: String): Boolean {
if (currentRoute == null) return false
if (currentRoute == newRoute) {
return true
}
if (newRoute.startsWith("Event/") && currentRoute.contains("/")) {
if (newRoute.split("/")[1] == currentRoute.split("/")[1]) {
return true
}
}
return false
}
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
// network is available for use
override fun onAvailable(network: Network) {
@@ -196,7 +262,14 @@ fun uriToRoute(uri: String?): String? {
}
}
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
Nip19.Type.ADDRESS ->
if (nip19.kind == CommunityDefinitionEvent.kind) {
"Community/${nip19.hex}"
} else if (nip19.kind == LiveActivitiesEvent.kind) {
"Channel/${nip19.hex}"
} else {
"Event/${nip19.hex}"
}
else -> null
}
} ?: try {

View File

@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
@@ -45,7 +44,6 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -62,9 +60,11 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
@@ -254,12 +254,7 @@ private fun SearchEditTextForJoin(
}
},
leadingIcon = {
Icon(
painter = painterResource(R.drawable.ic_search),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
SearchIcon(modifier = Size20Modifier, Color.Unspecified)
},
modifier = Modifier
.weight(1f, true)

View File

@@ -11,6 +11,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -56,7 +57,7 @@ open class NewMediaModel : ViewModel() {
}
}
fun upload(context: Context) {
fun upload(context: Context, relayList: List<Relay>? = null) {
isUploadingImage = true
val contentResolver = context.contentResolver
@@ -78,7 +79,7 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Loading"
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, description, sensitiveContent)
createNIP95Record(it.readBytes(), contentType, description, sensitiveContent, relayList = relayList)
}
?: run {
viewModelScope.launch {
@@ -98,7 +99,7 @@ open class NewMediaModel : ViewModel() {
server = serverToUse,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(imageUrl, mimeType, description, sensitiveContent)
createNIP94Record(imageUrl, mimeType, description, sensitiveContent, relayList = relayList)
},
onError = {
isUploadingImage = false
@@ -138,7 +139,7 @@ open class NewMediaModel : ViewModel() {
return !isUploadingImage && galleryUri != null && selectedServer != null
}
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean) {
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
uploadingPercentage.value = 0.40f
viewModelScope.launch(Dispatchers.IO) {
uploadingDescription.value = "Server Processing"
@@ -162,7 +163,7 @@ open class NewMediaModel : ViewModel() {
onReady = {
uploadingPercentage.value = 0.90f
uploadingDescription.value = "Sending"
account?.sendHeader(it)
account?.sendHeader(it, relayList)
uploadingPercentage.value = 1.00f
isUploadingImage = false
onceUploaded()
@@ -191,7 +192,7 @@ open class NewMediaModel : ViewModel() {
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean) {
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
uploadingPercentage.value = 0.30f
uploadingDescription.value = "Hashing"
@@ -210,7 +211,7 @@ open class NewMediaModel : ViewModel() {
if (nip95 != null) {
uploadingDescription.value = "Sending"
uploadingPercentage.value = 0.60f
account?.sendNip95(nip95.first, nip95.second)
account?.sendNip95(nip95.first, nip95.second, relayList)
}
uploadingPercentage.value = 1.00f

View File

@@ -7,17 +7,21 @@ import android.util.Size
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
@@ -33,6 +37,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
@@ -70,6 +75,17 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
}
}
var showRelaysDialog by remember {
mutableStateOf(false)
}
var relayList = account.activeRelays()?.filter {
it.write
}?.map {
it
} ?: account.convertLocalRelays().filter {
it.write
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
@@ -82,6 +98,20 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
modifier = Modifier
.fillMaxWidth()
) {
if (showRelaysDialog) {
RelaySelectionDialog(
list = relayList,
onClose = {
showRelaysDialog = false
},
onPost = {
relayList = it
},
accountViewModel = accountViewModel,
nav = nav
)
}
Column(
modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp)
.fillMaxWidth()
@@ -97,10 +127,26 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
onClose()
})
Box {
IconButton(
modifier = Modifier.align(Alignment.Center),
onClick = {
showRelaysDialog = true
}
) {
Icon(
painter = painterResource(R.drawable.relays),
contentDescription = null,
modifier = Modifier.height(25.dp),
tint = MaterialTheme.colors.onBackground
)
}
}
PostButton(
onPost = {
onClose()
postViewModel.upload(context)
postViewModel.upload(context, relayList)
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
},
isActive = postViewModel.canPost()
@@ -192,7 +238,10 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
}
} else {
postViewModel.galleryUri?.let {
VideoView(it.toString(), accountViewModel = accountViewModel)
VideoView(
videoUri = it.toString(),
accountViewModel = accountViewModel
)
}
}
}

View File

@@ -21,7 +21,6 @@ import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForwardIos
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.ShowChart
import androidx.compose.material.icons.filled.Visibility
@@ -73,7 +72,11 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.note.CancelIcon
import com.vitorpamplona.amethyst.ui.note.CloseIcon
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.PollIcon
import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
@@ -107,6 +110,16 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
var showRelaysDialog by remember {
mutableStateOf(false)
}
var relayList = account.activeRelays()?.filter {
it.write
}?.map {
it
} ?: account.convertLocalRelays().filter {
it.write
}
LaunchedEffect(Unit) {
postViewModel.load(account, baseReplyTo, quote)
@@ -141,6 +154,20 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
.fillMaxWidth()
.fillMaxHeight()
) {
if (showRelaysDialog) {
RelaySelectionDialog(
list = relayList,
onClose = {
showRelaysDialog = false
},
onPost = {
relayList = it
},
accountViewModel = accountViewModel,
nav = nav
)
}
Column(
modifier = Modifier
.fillMaxWidth()
@@ -162,10 +189,25 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
onClose()
})
Box {
IconButton(
modifier = Modifier.align(Alignment.Center),
onClick = {
showRelaysDialog = true
}
) {
Icon(
painter = painterResource(R.drawable.relays),
contentDescription = null,
modifier = Modifier.height(25.dp),
tint = MaterialTheme.colors.onBackground
)
}
}
PostButton(
onPost = {
scope.launch(Dispatchers.IO) {
postViewModel.sendPost()
postViewModel.sendPost(relayList = relayList)
onClose()
}
},
@@ -262,7 +304,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
url,
account.defaultFileServer,
onAdd = { description, server, sensitiveContent ->
postViewModel.upload(url, description, sensitiveContent, server, context)
postViewModel.upload(url, description, sensitiveContent, server, context, relayList)
account.changeDefaultFileServer(server)
},
onCancel = {
@@ -479,19 +521,9 @@ private fun AddPollButton(
}
) {
if (!isPollActive) {
Icon(
painter = painterResource(R.drawable.ic_poll),
null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onBackground
)
PollIcon()
} else {
Icon(
painter = painterResource(R.drawable.ic_lists),
null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onBackground
)
RegularPostIcon()
}
}
}
@@ -725,12 +757,7 @@ fun CloseButton(onCancel: () -> Unit) {
backgroundColor = Color.Gray
)
) {
Icon(
painter = painterResource(id = R.drawable.ic_close),
contentDescription = stringResource(id = R.string.cancel),
modifier = Modifier.size(20.dp),
tint = Color.White
)
CloseIcon()
}
}
@@ -792,30 +819,6 @@ fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
}
}
@Composable
fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
Button(
modifier = modifier,
onClick = {
if (isActive) {
onPost()
}
},
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Icon(
painter = painterResource(R.drawable.ic_search),
null,
modifier = Modifier.size(26.dp),
tint = Color.White
)
}
}
enum class ServersAvailable {
// IMGUR,
NOSTR_BUILD,
@@ -910,17 +913,10 @@ fun ImageVideoDescription(
)
IconButton(
modifier = Modifier.size(30.dp),
modifier = Modifier.size(30.dp).padding(end = 5.dp),
onClick = onCancel
) {
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier
.padding(end = 5.dp)
.size(30.dp),
tint = MaterialTheme.colors.placeholderText
)
CancelIcon()
}
}

View File

@@ -22,6 +22,7 @@ import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.isValidURL
import kotlinx.coroutines.Dispatchers
@@ -128,7 +129,7 @@ open class NewPostViewModel() : ViewModel() {
this.account = account
}
fun sendPost() {
fun sendPost(relayList: List<Relay>? = null) {
val tagger = NewMessageTagger(originalNote?.channelHex(), mentions, replyTos, message.text)
tagger.run()
@@ -145,7 +146,20 @@ open class NewPostViewModel() : ViewModel() {
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
if (wantsPoll) {
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
account?.sendPoll(
tagger.message,
tagger.replyTos,
tagger.mentions,
pollOptions,
valueMaximum,
valueMinimum,
consensusThreshold,
closedAt,
zapReceiver,
wantsToMarkAsSensitive,
localZapRaiserAmount,
relayList
)
} else if (originalNote?.channelHex() != null) {
if (originalNote is AddressableEvent && originalNote?.address() != null) {
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
@@ -172,14 +186,15 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
replyingTo = replyId,
root = rootId,
directMentions = tagger.directMentions
directMentions = tagger.directMentions,
relayList = relayList
)
}
cancel()
}
fun upload(galleryUri: Uri, description: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context) {
fun upload(galleryUri: Uri, description: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context, relayList: List<Relay>? = null) {
isUploadingImage = true
contentToAddUrl = null
@@ -194,7 +209,7 @@ open class NewPostViewModel() : ViewModel() {
onReady = { fileUri, contentType, size ->
if (server == ServersAvailable.NIP95) {
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, description, sensitiveContent)
createNIP95Record(it.readBytes(), contentType, description, sensitiveContent, relayList = relayList)
}
} else {
ImageUploader.uploadImage(
@@ -370,7 +385,7 @@ open class NewPostViewModel() : ViewModel() {
}
}
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean) {
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
viewModelScope.launch(Dispatchers.IO) {
// Images don't seem to be ready immediately after upload
FileHeader.prepare(
@@ -379,7 +394,7 @@ open class NewPostViewModel() : ViewModel() {
description,
sensitiveContent,
onReady = {
val note = account?.sendHeader(it)
val note = account?.sendHeader(it, relayList = relayList)
isUploadingImage = false
@@ -401,7 +416,7 @@ open class NewPostViewModel() : ViewModel() {
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean) {
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
@@ -411,7 +426,7 @@ open class NewPostViewModel() : ViewModel() {
sensitiveContent,
onReady = {
val nip95 = account?.createNip95(bytes, headerInfo = it)
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second) }
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second, relayList = relayList) }
isUploadingImage = false

View File

@@ -0,0 +1,183 @@
package com.vitorpamplona.amethyst.ui.actions
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.Surface
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
data class RelayList(
val relay: Relay,
val isSelected: Boolean
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RelaySelectionDialog(
list: List<Relay>,
onClose: () -> Unit,
onPost: (list: List<Relay>) -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val relayList = accountViewModel.account.activeRelays()?.filter {
it.write
}?.map {
it
} ?: accountViewModel.account.convertLocalRelays().filter {
it.write
}
var relays by remember {
mutableStateOf(
relayList.map {
RelayList(
it,
list.any { relay -> it.url == relay.url }
)
}
)
}
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
if (relayInfo != null) {
RelayInformationDialog(
onClose = {
relayInfo = null
},
relayInfo = relayInfo!!,
accountViewModel,
nav
)
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
decorFitsSystemWindows = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(
onCancel = {
onClose()
}
)
PostButton(
onPost = {
val selectedRelays = relays.filter { it.isSelected }
if (selectedRelays.isEmpty()) {
scope.launch {
Toast.makeText(context, "Select a relay to continue", Toast.LENGTH_SHORT).show()
}
return@PostButton
}
onPost(selectedRelays.map { it.relay })
onClose()
},
isActive = true
)
}
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
)
) {
itemsIndexed(
relays,
key = { _, item -> item.relay.url }
) { index, item ->
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
relays = relays.mapIndexed { j, item ->
if (index == j) {
item.copy(isSelected = !item.isSelected)
} else {
item
}
}
},
onLongClick = {
loadRelayInfo(item.relay.url, context, scope) {
relayInfo = it
}
}
)
) {
Text(
item.relay.url
.removePrefix("ws://")
.removePrefix("wss://")
.removeSuffix("/")
)
Switch(
checked = item.isSelected,
onCheckedChange = {
relays = relays.mapIndexed { j, item ->
if (index == j) {
item.copy(isSelected = !item.isSelected)
} else { item }
}
}
)
}
}
}
}
}
}
}

View File

@@ -38,11 +38,10 @@ class MediaCompressor {
// videoName = "compressed_video" // => required name
// ),
// OR AND NOT BOTH
appSpecificStorageConfiguration = AppSpecificStorageConfiguration(
videoName = UUID.randomUUID().toString() // => required name
),
appSpecificStorageConfiguration = AppSpecificStorageConfiguration(),
configureWith = Configuration(
quality = VideoQuality.LOW
quality = VideoQuality.LOW,
videoNames = listOf(UUID.randomUUID().toString()) // => required name
),
listener = object : CompressionListener {
override fun onProgress(index: Int, percent: Float) {

View File

@@ -11,12 +11,8 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Card
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -25,6 +21,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.theme.Size24dp
@Composable
fun SelectTextDialog(text: String, onDismiss: () -> Unit) {
@@ -41,7 +39,7 @@ fun SelectTextDialog(text: String, onDismiss: () -> Unit) {
) {
Card {
Column(
modifier = Modifier.heightIn(24.dp, maxHeight)
modifier = Modifier.heightIn(Size24dp, maxHeight)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -50,11 +48,7 @@ fun SelectTextDialog(text: String, onDismiss: () -> Unit) {
IconButton(
onClick = onDismiss
) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = null,
tint = MaterialTheme.colors.primary
)
ArrowBackIcon(Size24dp)
}
Text(text = stringResource(R.string.select_text_dialog_top))
}

View File

@@ -1,27 +1,25 @@
package com.vitorpamplona.amethyst.ui.components
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -30,12 +28,13 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
@@ -47,34 +46,41 @@ import androidx.compose.ui.unit.isFinite
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import coil.imageLoader
import coil.request.ImageRequest
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.upstream.DataSource
import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.PlaybackClientController
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.ui.note.LyricsIcon
import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon
import com.vitorpamplona.amethyst.ui.note.MuteIcon
import com.vitorpamplona.amethyst.ui.note.MutedIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import java.util.UUID
import kotlin.math.abs
public var DefaultMutedSetting = mutableStateOf(true)
@Composable
fun LoadThumbAndThenVideoView(
videoUri: String,
description: String? = null,
title: String? = null,
thumbUri: String,
authorName: String? = null,
nostrUriCallback: String? = null,
accountViewModel: AccountViewModel,
onDialog: ((Boolean) -> Unit)? = null
) {
@@ -101,94 +107,73 @@ fun LoadThumbAndThenVideoView(
if (loadingFinished.first) {
if (loadingFinished.second != null) {
VideoView(videoUri, description, VideoThumb(loadingFinished.second), accountViewModel, onDialog = onDialog)
VideoView(
videoUri = videoUri,
title = title,
thumb = VideoThumb(loadingFinished.second),
artworkUri = thumbUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
accountViewModel = accountViewModel,
onDialog = onDialog
)
} else {
VideoView(videoUri, description, null, accountViewModel, onDialog = onDialog)
VideoView(
videoUri = videoUri,
title = title,
thumb = null,
artworkUri = thumbUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
accountViewModel = accountViewModel,
onDialog = onDialog
)
}
}
}
@OptIn(ExperimentalTime::class)
@Composable
fun VideoView(
videoUri: String,
description: String? = null,
title: String? = null,
thumb: VideoThumb? = null,
artworkUri: String? = null,
authorName: String? = null,
nostrUriCallback: String? = null,
onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false
) {
val defaultToStart by remember(videoUri) { mutableStateOf(DefaultMutedSetting.value) }
VideoViewInner(
videoUri,
defaultToStart,
title,
thumb,
artworkUri,
authorName,
nostrUriCallback,
alwaysShowVideo,
accountViewModel,
onDialog
)
}
@Composable
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun VideoViewInner(
videoUri: String,
defaultToStart: Boolean = false,
title: String? = null,
thumb: VideoThumb? = null,
artworkUri: String? = null,
authorName: String? = null,
nostrUriCallback: String? = null,
alwaysShowVideo: Boolean = false,
accountViewModel: AccountViewModel,
onDialog: ((Boolean) -> Unit)? = null
) {
val (value, elapsed) = measureTimedValue {
VideoView1(videoUri, description, thumb, onDialog, accountViewModel, alwaysShowVideo)
}
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
}
@Composable
fun VideoView1(
videoUri: String,
description: String? = null,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false
) {
var exoPlayerData by remember { mutableStateOf<VideoPlayer?>(null) }
val defaultToStart by remember { mutableStateOf(DefaultMutedSetting.value) }
val context = LocalContext.current
LaunchedEffect(key1 = videoUri) {
if (exoPlayerData == null) {
launch(Dispatchers.Default) {
exoPlayerData = VideoPlayer(ExoPlayer.Builder(context).build())
}
}
}
exoPlayerData?.let {
VideoView(videoUri, description, it, defaultToStart, thumb, onDialog, accountViewModel, alwaysShowVideo)
}
DisposableEffect(Unit) {
onDispose {
exoPlayerData?.exoPlayer?.release()
}
}
}
@OptIn(ExperimentalTime::class)
@Composable
fun VideoView(
videoUri: String,
description: String? = null,
exoPlayerData: VideoPlayer,
defaultToStart: Boolean = false,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false
) {
val (_, elapsed) = measureTimedValue {
VideoView1(videoUri, description, exoPlayerData, defaultToStart, thumb, onDialog, accountViewModel, alwaysShowVideo)
}
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
}
@Composable
fun VideoView1(
videoUri: String,
description: String? = null,
exoPlayerData: VideoPlayer,
defaultToStart: Boolean = false,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false
) {
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
val media = remember { MediaItem.Builder().setUri(videoUri).build() }
val settings = accountViewModel.account.settings
val isMobile = ConnectivityStatus.isOnMobileData.value
@@ -204,58 +189,281 @@ fun VideoView1(
)
}
exoPlayerData.exoPlayer.apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
volume = if (defaultToStart) 0f else 1f
if (videoUri.startsWith("file")) {
setMediaItem(media)
} else if (videoUri.endsWith("m3u8")) {
// Should not use cache.
val dataSourceFactory: DataSource.Factory = OkHttpDataSource.Factory(HttpClient.getHttpClient())
setMediaSource(
HlsMediaSource.Factory(dataSourceFactory).createMediaSource(
media
)
)
} else {
setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
media
)
)
}
prepare()
}
if (!automaticallyStartPlayback.value) {
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
} else {
RenderVideoPlayer(exoPlayerData, thumb, automaticallyStartPlayback, onDialog)
}
DisposableEffect(Unit) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
exoPlayerData.exoPlayer.pause()
}
else -> {}
VideoPlayerMutex(videoUri) { activeOnScreen ->
val mediaItem = remember(videoUri) {
MediaItem.Builder()
.setMediaId(videoUri)
.setUri(videoUri)
.setMediaMetadata(
MediaMetadata.Builder()
.setArtist(authorName?.ifBlank { null })
.setTitle(title?.ifBlank { null } ?: videoUri)
.setArtworkUri(
try {
if (artworkUri != null) {
Uri.parse(artworkUri)
} else {
null
}
} catch (e: Exception) {
null
}
)
.build()
)
.build()
}
}
val lifecycle = lifecycleOwner.value.lifecycle
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
GetVideoController(
mediaItem = mediaItem,
videoUri = videoUri,
defaultToStart = defaultToStart,
nostrUriCallback = nostrUriCallback
) { controller, keepPlaying ->
RenderVideoPlayer(controller, thumb, keepPlaying, automaticallyStartPlayback, activeOnScreen, onDialog)
}
}
}
}
@Composable
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun GetVideoController(
mediaItem: MediaItem,
videoUri: String,
defaultToStart: Boolean = false,
nostrUriCallback: String? = null,
inner: @Composable (controller: MediaController, keepPlaying: MutableState<Boolean>) -> Unit
) {
val context = LocalContext.current
val controller = remember(videoUri) {
mutableStateOf<MediaController?>(
if (videoUri == keepPlayingMutex?.currentMediaItem?.mediaId) keepPlayingMutex else null
)
}
val keepPlaying = remember(videoUri) {
mutableStateOf<Boolean>(
keepPlayingMutex != null && controller.value == keepPlayingMutex
)
}
val uid = remember(videoUri) {
UUID.randomUUID().toString()
}
// Prepares a VideoPlayer from the foreground service.
LaunchedEffect(key1 = videoUri) {
// If it is not null, the user might have come back from a playing video, like clicking on
// the notification of the video player.
if (controller.value == null) {
launch(Dispatchers.IO) {
PlaybackClientController.prepareController(
uid,
videoUri,
nostrUriCallback,
context
) {
// checks again because of race conditions.
if (controller.value == null) { // still prone to race conditions.
controller.value = it
if (!it.isPlaying) {
if (keepPlayingMutex?.isPlaying == true) {
// There is a video playing, start this one on mute.
controller.value?.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
controller.value?.volume = if (defaultToStart) 0f else 1f
}
}
controller.value?.setMediaItem(mediaItem)
controller.value?.prepare()
} else if (controller.value != it) {
// discards the new controller because there is an existing one
it.stop()
it.release()
controller.value?.let {
if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
if (it.isPlaying) {
// There is a video playing, start this one on mute.
it.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
it.volume = if (defaultToStart) 0f else 1f
}
it.setMediaItem(mediaItem)
it.prepare()
}
}
}
}
}
} else {
controller.value?.let {
if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
if (it.isPlaying) {
// There is a video playing, start this one on mute.
it.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
it.volume = if (defaultToStart) 0f else 1f
}
it.setMediaItem(mediaItem)
it.prepare()
}
}
}
}
controller.value?.let {
inner(it, keepPlaying)
}
// User pauses and resumes the app. What to do with videos?
val scope = rememberCoroutineScope()
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(key1 = videoUri) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
// if the controller is null, restarts the controller with a new one
// if the controller is not null, just continue playing what the controller was playing
if (controller.value == null) {
scope.launch(Dispatchers.IO) {
PlaybackClientController.prepareController(
UUID.randomUUID().toString(),
videoUri,
nostrUriCallback,
context
) {
// checks again to make sure no other thread has created a controller.
if (controller.value == null) {
controller.value = it
if (!it.isPlaying) {
if (keepPlayingMutex?.isPlaying == true) {
// There is a video playing, start this one on mute.
controller.value?.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
controller.value?.volume = if (defaultToStart) 0f else 1f
}
}
controller.value?.setMediaItem(mediaItem)
controller.value?.prepare()
} else if (controller.value != it) {
// discards the new controller because there is an existing one
it.stop()
it.release()
}
}
}
}
}
if (event == Lifecycle.Event.ON_PAUSE) {
if (!keepPlaying.value) {
// Stops and releases the media.
controller.value?.stop()
controller.value?.release()
controller.value = null
}
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
if (!keepPlaying.value) {
// Stops and releases the media.
controller.value?.stop()
controller.value?.release()
controller.value = null
}
}
}
}
// background playing mutex.
var keepPlayingMutex: MediaController? = null
// This keeps the position of all visible videos in the current screen.
val trackingVideos = mutableListOf<VisibilityData>()
@Stable
data class VideoPlayer(
val exoPlayer: ExoPlayer
)
class VisibilityData() {
var distanceToCenter: Float? = null
}
/**
* This function selects only one Video to be active. The video that is closest to the center of
* the screen wins the mutex.
*/
@Composable
fun VideoPlayerMutex(videoUri: String, inner: @Composable (MutableState<Boolean>) -> Unit) {
val myCache = remember(videoUri) {
VisibilityData()
}
// Is the current video the closest to the center?
val active = remember(videoUri) {
mutableStateOf<Boolean>(false)
}
// Keep track of all available videos.
DisposableEffect(key1 = videoUri) {
trackingVideos.add(myCache)
onDispose {
trackingVideos.remove(myCache)
}
}
Box(
Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 70.dp)
.onVisiblePositionChanges { distanceToCenter ->
myCache.distanceToCenter = distanceToCenter
if (distanceToCenter != null) {
// finds out of the current video is the closest to the center.
var newActive = true
for (video in trackingVideos) {
val videoPos = video.distanceToCenter
if (videoPos != null && videoPos < distanceToCenter) {
newActive = false
break
}
}
// marks the current video active
if (active.value != newActive) {
active.value = newActive
}
} else {
// got out of screen, marks video as inactive
if (active.value) {
active.value = false
}
}
}
) {
inner(active)
}
}
@Stable
data class VideoThumb(
@@ -263,35 +471,32 @@ data class VideoThumb(
)
@Composable
@OptIn(UnstableApi::class)
private fun RenderVideoPlayer(
playerData: VideoPlayer,
controller: MediaController,
thumbData: VideoThumb?,
keepPlaying: MutableState<Boolean>,
automaticallyStartPlayback: MutableState<Boolean>,
activeOnScreen: MutableState<Boolean>,
onDialog: ((Boolean) -> Unit)?
) {
val context = LocalContext.current
ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen)
val controllerVisible = remember(controller) {
mutableStateOf(false)
}
BoxWithConstraints() {
AndroidView(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 70.dp)
.align(Alignment.Center)
.onVisibilityChanges { visible ->
if (!automaticallyStartPlayback.value) {
playerData.exoPlayer.stop()
}
if (!automaticallyStartPlayback.value && visible && !playerData.exoPlayer.isPlaying) {
playerData.exoPlayer.pause()
} else if (visible && !playerData.exoPlayer.isPlaying) {
playerData.exoPlayer.play()
} else if (!visible && playerData.exoPlayer.isPlaying) {
playerData.exoPlayer.pause()
}
},
.align(Alignment.Center),
factory = {
StyledPlayerView(context).apply {
player = playerData.exoPlayer
PlayerView(context).apply {
player = controller
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
@@ -303,18 +508,102 @@ private fun RenderVideoPlayer(
if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
playerData.exoPlayer.pause()
controller.pause()
innerOnDialog(it)
}
}
setControllerVisibilityListener(
PlayerView.ControllerVisibilityListener {
controllerVisible.value = it == View.VISIBLE
}
)
}
}
)
MuteButton() { mute: Boolean ->
val startingMuteState = remember(controller) {
controller.volume < 0.001
}
MuteButton(controllerVisible, startingMuteState) { mute: Boolean ->
// makes the new setting the default for new creations.
DefaultMutedSetting.value = mute
playerData.exoPlayer.volume = if (mute) 0f else 1f
// if the user unmutes a video and it's not the current playing, switches to that one.
if (!mute && keepPlayingMutex != null && keepPlayingMutex != controller) {
keepPlayingMutex?.stop()
keepPlayingMutex = null
}
controller.volume = if (mute) 0f else 1f
}
KeepPlayingButton(keepPlaying, controllerVisible, Modifier.align(Alignment.TopEnd)) { newKeepPlaying: Boolean ->
// If something else is playing and the user marks this video to keep playing, stops the other one.
if (newKeepPlaying) {
if (keepPlayingMutex != null && keepPlayingMutex != controller) {
keepPlayingMutex?.stop()
}
keepPlayingMutex = controller
} else {
if (keepPlayingMutex == controller) {
keepPlayingMutex = null
}
}
keepPlaying.value = newKeepPlaying
}
}
}
@Composable
fun ControlWhenPlayerIsActive(
controller: Player,
keepPlaying: MutableState<Boolean>,
automaticallyStartPlayback: MutableState<Boolean>,
activeOnScreen: MutableState<Boolean>
) {
// active means being fully visible
if (activeOnScreen.value) {
// should auto start video from settings?
if (!automaticallyStartPlayback.value) {
if (controller.isPlaying) {
// if it is visible, it's playing but it wasn't supposed to start automatically.
controller.pause()
}
} else if (!controller.isPlaying) {
// if it is visible, was supposed to start automatically, but it's not
// If something else is playing, play on mute.
if (keepPlayingMutex != null && keepPlayingMutex != controller) {
controller.volume = 0f
}
controller.play()
}
} else {
// Pauses the video when it becomes invisible.
// Destroys the video later when it Disposes the element
// meanwhile if the user comes back, the position in the track is saved.
if (!keepPlaying.value) {
controller.pause()
}
}
val view = LocalView.current
// Keeps the screen on while playing and viewing videos.
DisposableEffect(key1 = controller) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
// doesn't consider the mutex because the screen can turn off if the video
// being played in the mutex is not visible.
view.keepScreenOn = isPlaying
}
}
controller.addListener(listener)
onDispose {
controller.removeListener(listener)
}
}
}
@@ -335,12 +624,15 @@ fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifi
fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
if (!isAttached) return false
// Window relative bounds of our compose root view that are visible on the screen
val globalRootRect = android.graphics.Rect()
val globalRootRect = Rect()
if (!view.getGlobalVisibleRect(globalRootRect)) {
// we aren't visible at all.
return false
}
val bounds = boundsInWindow()
if (bounds.isEmpty) return false
// Make sure we are completely in bounds.
return bounds.top >= globalRootRect.top &&
bounds.left >= globalRootRect.left &&
@@ -348,47 +640,127 @@ fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
bounds.bottom <= globalRootRect.bottom
}
@Composable
private fun MuteButton(toggle: (Boolean) -> Unit) {
Box(
remember {
Modifier
.width(70.dp)
.height(70.dp)
.padding(10.dp)
}
fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Float?) -> Unit): Modifier = composed {
val view = LocalView.current
onGloballyPositioned { coordinates ->
onVisiblePosition(coordinates.getDistanceToVertCenterIfVisible(view))
}
}
fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? {
if (!isAttached) return null
// Window relative bounds of our compose root view that are visible on the screen
val globalRootRect = Rect()
if (!view.getGlobalVisibleRect(globalRootRect)) {
// we aren't visible at all.
return null
}
val bounds = boundsInWindow()
if (bounds.isEmpty) return null
// Make sure we are completely in bounds.
if (bounds.top >= globalRootRect.top &&
bounds.left >= globalRootRect.left &&
bounds.right <= globalRootRect.right &&
bounds.bottom <= globalRootRect.bottom
) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
return abs(((bounds.top + bounds.bottom) / 2) - ((globalRootRect.top + globalRootRect.bottom) / 2))
}
return null
}
@Composable
private fun MuteButton(
controllerVisible: MutableState<Boolean>,
startingMuteState: Boolean,
toggle: (Boolean) -> Unit
) {
val holdOn = remember {
mutableStateOf<Boolean>(
true
)
}
val mutedInstance = remember { mutableStateOf(DefaultMutedSetting.value) }
LaunchedEffect(key1 = controllerVisible) {
launch(Dispatchers.Default) {
delay(2000)
holdOn.value = false
}
}
IconButton(
onClick = {
mutedInstance.value = !mutedInstance.value
toggle(mutedInstance.value)
},
modifier = Modifier.size(50.dp)
) {
if (mutedInstance.value) {
Icon(
imageVector = Icons.Default.VolumeOff,
"Hash Verified",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.size(30.dp)
)
} else {
Icon(
imageVector = Icons.Default.VolumeUp,
"Hash Verified",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.size(30.dp)
)
val mutedInstance = remember(startingMuteState) { mutableStateOf(startingMuteState) }
AnimatedVisibility(
visible = holdOn.value || controllerVisible.value,
enter = fadeIn(),
exit = fadeOut()
) {
Box(modifier = VolumeBottomIconSize) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
)
IconButton(
onClick = {
mutedInstance.value = !mutedInstance.value
toggle(mutedInstance.value)
},
modifier = Size50Modifier
) {
if (mutedInstance.value) {
MutedIcon()
} else {
MuteIcon()
}
}
}
}
}
@Composable
private fun KeepPlayingButton(
keepPlayingStart: MutableState<Boolean>,
controllerVisible: MutableState<Boolean>,
alignment: Modifier,
toggle: (Boolean) -> Unit
) {
val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) }
AnimatedVisibility(
visible = controllerVisible.value,
modifier = alignment,
enter = fadeIn(),
exit = fadeOut()
) {
Box(modifier = PinBottomIconSize) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
)
IconButton(
onClick = {
keepPlaying.value = !keepPlaying.value
toggle(keepPlaying.value)
},
modifier = Size50Modifier
) {
if (keepPlaying.value) {
LyricsIcon(Size22Modifier, Color.White)
} else {
LyricsOffIcon(Size22Modifier, Color.White)
}
}
}
}

View File

@@ -1,8 +1,11 @@
package com.vitorpamplona.amethyst.ui.components
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.os.Build
import android.util.Log
import android.view.Window
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
@@ -26,15 +29,11 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DownloadForOffline
import androidx.compose.material.icons.filled.Report
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
@@ -47,12 +46,10 @@ import androidx.compose.runtime.rememberCoroutineScope
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.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
@@ -62,6 +59,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isFinite
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.net.toUri
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
@@ -75,8 +73,14 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton
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.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon
import com.vitorpamplona.amethyst.ui.note.HashCheckIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Font17SP
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -119,7 +123,9 @@ class ZoomableUrlVideo(
description: String? = null,
hash: String? = null,
dim: String? = null,
uri: String? = null
uri: String? = null,
val artworkUri: String? = null,
val authorName: String? = null
) : ZoomableUrlContent(url, description, hash, dim, uri)
@Immutable
@@ -150,7 +156,9 @@ class ZoomableLocalVideo(
description: String? = null,
dim: String? = null,
isVerified: Boolean? = null,
uri: String
uri: String,
val artworkUri: String? = null,
val authorName: String? = null
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
fun figureOutMimeType(fullUrl: String): ZoomableContent {
@@ -202,19 +210,27 @@ fun ZoomableContentView(
when (content) {
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier, accountViewModel)
is ZoomableUrlVideo -> VideoView(
content.url,
content.description,
videoUri = content.url,
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
nostrUriCallback = content.uri,
onDialog = { dialogOpen = true },
accountViewModel = accountViewModel
) { dialogOpen = true }
)
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier, accountViewModel)
is ZoomableLocalVideo ->
content.localFile?.let {
VideoView(
it.toUri().toString(),
content.description,
videoUri = it.toUri().toString(),
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
nostrUriCallback = content.uri,
onDialog = { dialogOpen = true },
accountViewModel = accountViewModel
) { dialogOpen = true }
)
}
}
@@ -367,15 +383,10 @@ fun ImageUrlWithDownloadButton(url: String, showImage: MutableState<Boolean>) {
FlowRow() {
ClickableUrl(urlText = url, url = url)
IconButton(
modifier = Modifier.size(20.dp),
modifier = Modifier.size(Size20dp),
onClick = { showImage.value = true }
) {
Icon(
imageVector = Icons.Default.DownloadForOffline,
null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary
)
DownloadForOfflineIcon(Size24dp)
}
}
}
@@ -657,7 +668,14 @@ private fun RenderImageOrVideo(content: ZoomableContent, accountViewModel: Accou
UrlImageView(content = content, mainImageModifier = mainModifier, accountViewModel, alwayShowImage = true)
} else if (content is ZoomableUrlVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.url, content.description, accountViewModel = accountViewModel, alwaysShowVideo = true)
VideoView(
videoUri = content.url,
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
accountViewModel = accountViewModel,
alwaysShowVideo = true
)
}
} else if (content is ZoomableLocalImage) {
LocalImageView(content = content, mainImageModifier = mainModifier, accountViewModel, alwayShowImage = true)
@@ -665,8 +683,10 @@ private fun RenderImageOrVideo(content: ZoomableContent, accountViewModel: Accou
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
content.localFile?.let {
VideoView(
it.toUri().toString(),
content.description,
videoUri = it.toUri().toString(),
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
accountViewModel = accountViewModel,
alwaysShowVideo = true
)
@@ -717,12 +737,7 @@ private fun HashVerificationSymbol(verifiedHash: Boolean, modifier: Modifier) {
}
}
) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"Hash Verified",
tint = Color.Unspecified,
modifier = Modifier.size(30.dp)
)
HashCheckIcon(Size30dp)
}
} else {
IconButton(
@@ -736,13 +751,22 @@ private fun HashVerificationSymbol(verifiedHash: Boolean, modifier: Modifier) {
}
}
) {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Hash",
modifier = Modifier.size(30.dp)
)
HashCheckFailedIcon(Size30dp)
}
}
}
}
// Window utils
@Composable
fun getDialogWindow(): Window? = (LocalView.current.parent as? DialogWindowProvider)?.window
@Composable
fun getActivityWindow(): Window? = LocalView.current.context.getActivityWindow()
private tailrec fun Context.getActivityWindow(): Window? =
when (this) {
is Activity -> window
is ContextWrapper -> baseContext.getActivityWindow()
else -> null
}

View File

@@ -32,7 +32,9 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
?.lastOrNull { it.event != null }
}
val publicChannels = account.followingChannels().map { it ->
val publicChannels = account.selectedChatsFollowList().mapNotNull {
LocalCache.getChannelIfExists(it)
}.mapNotNull { it ->
it.notes.values
.filter { account.isAcceptable(it) && it.event != null }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
@@ -40,7 +42,6 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
}
return (privateMessages + publicChannels)
.filterNotNull()
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
@@ -106,7 +107,7 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
}
private fun filterRelevantPublicMessages(newItems: Set<Note>, account: Account): MutableMap<String, Note> {
val followingChannels = account.followingChannels
val followingChannels = account.userProfile().latestContactList?.taggedEvents()?.toSet() ?: emptySet()
val newRelevantPublicMessages = mutableMapOf<String, Note>()
newItems.filter { it.event is ChannelMessageEvent }.forEach { newNote ->
newNote.channelHex()?.let { channelHex ->

View File

@@ -13,6 +13,10 @@ open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Not
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
}
override fun showHiddenKey(): Boolean {
return account.defaultDiscoveryFollowList == PeopleListEvent.blockList
}
override fun feed(): List<Note> {
val allChannelNotes = LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) }
@@ -28,6 +32,7 @@ open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Not
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = TimeUtils.now()
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
val isHiddenList = showHiddenKey()
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
@@ -36,15 +41,15 @@ open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Not
val anyOtherChannelEvent = collection
.asSequence()
.filter { it.event is IsInPublicChatChannel }
.mapNotNull { (it.event as? ChannelMessageEvent)?.channel() }
.mapNotNull { (it.event as? IsInPublicChatChannel)?.channel() }
.mapNotNull { LocalCache.checkGetOrCreateNote(it) }
.toSet()
val activities = (createEvents + anyOtherChannelEvent)
.asSequence()
.filter { it.event is ChannelCreateEvent }
// .filter { it.event is ChannelCreateEvent } // Event heads might not be loaded yet.
.filter { isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true }
.filter { it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
.filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
.filter { (it.createdAt() ?: 0) <= now }
.toSet()

View File

@@ -13,6 +13,10 @@ open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilte
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
}
override fun showHiddenKey(): Boolean {
return account.defaultDiscoveryFollowList == PeopleListEvent.blockList
}
override fun feed(): List<Note> {
val allNotes = LocalCache.addressables.values
@@ -28,15 +32,24 @@ open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilte
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = TimeUtils.now()
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
val isHiddenList = showHiddenKey()
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val activities = collection
val createEvents = collection.filter { it.event is CommunityDefinitionEvent }
val anyOtherCommunityEvent = collection
.asSequence()
.filter { it.event is CommunityPostApprovalEvent }
.mapNotNull { (it.event as? CommunityPostApprovalEvent)?.communities() }.flatten()
.map { LocalCache.getOrCreateAddressableNote(it) }
.toSet()
val activities = (createEvents + anyOtherCommunityEvent)
.asSequence()
.filter { it.event is CommunityDefinitionEvent }
.filter { isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true }
.filter { it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
.filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
.filter { (it.createdAt() ?: 0) <= now }
.toSet()

View File

@@ -16,6 +16,10 @@ open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter<Not
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
}
override fun showHiddenKey(): Boolean {
return account.defaultDiscoveryFollowList == PeopleListEvent.blockList
}
override fun feed(): List<Note> {
val allChannelNotes =
LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) }
@@ -33,6 +37,7 @@ open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter<Not
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = TimeUtils.now()
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
val isHiddenList = showHiddenKey()
val followingKeySet =
account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
@@ -47,7 +52,7 @@ open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter<Not
followingTagSet
) == true
}
.filter { it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
.filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
.filter { (it.createdAt() ?: 0) <= now }
.toSet()

View File

@@ -24,6 +24,7 @@ abstract class FeedFilter<T> {
* Returns a string that serves as the key to invalidate the list if it changes.
*/
abstract fun feedKey(): String
open fun showHiddenKey(): Boolean = false
abstract fun feed(): List<T>
}

View File

@@ -9,8 +9,28 @@ class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
return account.userProfile().pubkeyHex
}
override fun showHiddenKey(): Boolean {
return true
}
override fun feed(): List<User> {
return (account.hiddenUsers + account.transientHiddenUsers)
.map { LocalCache.getOrCreateUser(it) }
return account.getBlockList()
?.publicAndPrivateUsers(account.loggedIn.privKey)
?.map { LocalCache.getOrCreateUser(it) }
?: emptyList()
}
}
class SpammerAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
override fun showHiddenKey(): Boolean {
return true
}
override fun feed(): List<User> {
return (account.transientHiddenUsers).map { LocalCache.getOrCreateUser(it) }
}
}

View File

@@ -7,6 +7,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@@ -16,6 +17,10 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList
}
override fun showHiddenKey(): Boolean {
return account.defaultHomeFollowList == PeopleListEvent.blockList
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
@@ -26,6 +31,7 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS
val isHiddenList = showHiddenKey()
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
@@ -38,7 +44,7 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
(it.event is TextNoteEvent || it.event is PollNoteEvent || it.event is ChannelMessageEvent || it.event is LiveActivitiesChatMessageEvent) &&
(isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) ?: false) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
(isHiddenList || it.author?.let { !account.isHidden(it) } ?: true) &&
((it.event?.createdAt() ?: 0) < now) &&
!it.isNewThread()
}

View File

@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@@ -19,6 +20,10 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList
}
override fun showHiddenKey(): Boolean {
return account.defaultHomeFollowList == PeopleListEvent.blockList
}
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
@@ -32,9 +37,11 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS
val isHiddenList = showHiddenKey()
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingCommunities = account.selectedCommunitiesFollowList(account.defaultHomeFollowList) ?: emptySet()
val oneMinuteInTheFuture = TimeUtils.now() + (1 * 60) // one minute in the future.
val oneHr = 60 * 60
@@ -44,9 +51,9 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
.filter { it ->
val noteEvent = it.event
(noteEvent is TextNoteEvent || noteEvent is ClassifiedsEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent) &&
(isGlobal || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet)) &&
(isGlobal || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet) || noteEvent.isTaggedAddressableNotes(followingCommunities)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
(isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true) &&
((it.event?.createdAt() ?: 0) < oneMinuteInTheFuture) &&
it.isNewThread() &&
(

View File

@@ -12,6 +12,10 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
return account.userProfile().pubkeyHex + "-" + account.defaultNotificationFollowList
}
override fun showHiddenKey(): Boolean {
return account.defaultNotificationFollowList == PeopleListEvent.blockList
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
@@ -22,6 +26,7 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val isGlobal = account.defaultNotificationFollowList == GLOBAL_FOLLOWS
val isHiddenList = showHiddenKey()
val followingKeySet = account.selectedUsersFollowList(account.defaultNotificationFollowList) ?: emptySet()
@@ -37,7 +42,7 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
it.author !== loggedInUser &&
(isGlobal || it.author?.pubkeyHex in followingKeySet) &&
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
(it.author == null || !account.isHidden(it.author!!.pubkeyHex)) &&
(isHiddenList || it.author == null || !account.isHidden(it.author!!.pubkeyHex)) &&
tagsAnEventByUser(it, loggedInUserHex)
}.toSet()
}

View File

@@ -12,6 +12,10 @@ class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
return account.userProfile().pubkeyHex + "-" + account.defaultStoriesFollowList
}
override fun showHiddenKey(): Boolean {
return account.defaultStoriesFollowList == PeopleListEvent.blockList
}
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
@@ -25,6 +29,7 @@ class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = TimeUtils.now()
val isGlobal = account.defaultStoriesFollowList == GLOBAL_FOLLOWS
val isHiddenList = account.defaultStoriesFollowList == PeopleListEvent.blockList
val followingKeySet = account.selectedUsersFollowList(account.defaultStoriesFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultStoriesFollowList) ?: emptySet()
@@ -33,7 +38,7 @@ class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
.asSequence()
.filter { it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent }
.filter { isGlobal || it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false) }
.filter { account.isAcceptable(it) }
.filter { isHiddenList || account.isAcceptable(it) }
.filter { it.createdAt()!! <= now }
.toSet()
}

View File

@@ -2,11 +2,8 @@ package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@@ -56,11 +53,8 @@ fun AppNavigation(
navController: NavHostController,
accountViewModel: AccountViewModel,
themeViewModel: ThemeViewModel,
nextPage: String? = null
themeViewModel: ThemeViewModel
) {
var actionableNextPage by remember { mutableStateOf<String?>(nextPage) }
val scope = rememberCoroutineScope()
val nav = remember {
{ route: String ->
@@ -232,11 +226,4 @@ fun AppNavigation(
})
}
}
actionableNextPage?.let {
LaunchedEffect(it) {
nav(it)
}
actionableNextPage = null
}
}

View File

@@ -77,10 +77,12 @@ import com.vitorpamplona.amethyst.service.model.PeopleListEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -246,7 +248,7 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
}
},
actions = {
SearchIcon() {
SearchButton() {
nav(Route.Search.route)
}
}
@@ -256,16 +258,11 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
}
@Composable
private fun SearchIcon(onClick: () -> Unit) {
private fun SearchButton(onClick: () -> Unit) {
IconButton(
onClick = onClick
) {
Icon(
painter = painterResource(R.drawable.ic_search),
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = Color.Unspecified
)
SearchIcon(modifier = Size22Modifier, Color.Unspecified)
}
}

View File

@@ -160,25 +160,23 @@ fun ProfileContent(
}
Column(modifier = modifier) {
profilePicture?.let {
RobohashAsyncImageProxy(
robot = profilePubHex,
model = profilePicture,
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(100.dp)
.height(100.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colors.background, CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = {
nav(route)
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})
)
}
RobohashAsyncImageProxy(
robot = profilePubHex,
model = profilePicture,
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(100.dp)
.height(100.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colors.background, CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = {
nav(route)
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})
)
if (bestDisplayName != null) {
CreateTextWithEmoji(

View File

@@ -3,8 +3,10 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavHostController
import androidx.navigation.NavType
@@ -290,6 +292,12 @@ fun getRouteWithArguments(navController: NavHostController): String? {
return getRouteWithArguments(currentEntry.destination, currentEntry.arguments)
}
fun getRouteWithArguments(navState: State<NavBackStackEntry?>): String? {
return navState.value?.let {
getRouteWithArguments(it.destination, it.arguments)
}
}
private fun getRouteWithArguments(
destination: NavDestination,
arguments: Bundle?

View File

@@ -43,7 +43,7 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForLastRead: String, showHidden: Boolean = false, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteState by likeSetCard.note.live().metadata.observeAsState()
val note = noteState?.note
@@ -158,6 +158,7 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
baseNote = it,
routeForLastRead = null,
isBoostedNote = true,
showHidden = showHidden,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav

View File

@@ -86,6 +86,8 @@ fun ChannelCardCompose(
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
forceEventKind: Int?,
showHidden: Boolean = false,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -107,14 +109,17 @@ fun ChannelCardCompose(
)
}
} else {
CheckHiddenChannelCardCompose(
baseNote,
routeForLastRead,
modifier,
parentBackgroundColor,
accountViewModel,
nav
)
if (forceEventKind == null || baseNote.event?.kind() == forceEventKind) {
CheckHiddenChannelCardCompose(
baseNote,
routeForLastRead,
modifier,
parentBackgroundColor,
showHidden,
accountViewModel,
nav
)
}
}
}
}
@@ -125,24 +130,47 @@ fun CheckHiddenChannelCardCompose(
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
showHidden: Boolean,
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
if (showHidden) {
var state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
)
}
RenderChannelCardReportState(
state = state,
note = note,
routeForLastRead = routeForLastRead,
modifier = modifier,
parentBackgroundColor = parentBackgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
} else {
val isHidden by accountViewModel.account.liveHiddenUsers.map {
note.isHiddenFor(it)
}.distinctUntilChanged().observeAsState(accountViewModel.isNoteHidden(note))
Crossfade(targetState = isHidden) {
if (!it) {
LoadedChannelCardCompose(
note,
routeForLastRead,
modifier,
parentBackgroundColor,
accountViewModel,
nav
)
}
}
}
}
@@ -632,11 +660,11 @@ fun RenderChannelThumb(baseNote: Note, accountViewModel: AccountViewModel, nav:
fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val channelUpdates by channel.live.observeAsState()
val name = remember(channelUpdates) { channel.toBestDisplayName() }
val description = remember(channelUpdates) { channel.summary() }
val name = remember(channelUpdates) { channelUpdates?.channel?.toBestDisplayName() ?: "" }
val description = remember(channelUpdates) { channelUpdates?.channel?.summary() }
val cover by remember(channelUpdates) {
derivedStateOf {
channel.profilePicture()?.ifBlank { null }
channelUpdates?.channel?.profilePicture()?.ifBlank { null }
}
}

View File

@@ -0,0 +1,379 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.DownloadForOffline
import androidx.compose.material.icons.filled.Downloading
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.subtleButton
@Composable
fun FollowingIcon(iconSize: Dp) {
Icon(
painter = painterResource(R.drawable.following),
contentDescription = stringResource(id = R.string.following),
modifier = remember(iconSize) { Modifier.size(iconSize) },
tint = Color.Unspecified
)
}
@Composable
fun ArrowBackIcon(iconSize: Dp) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = null,
modifier = remember(iconSize) { Modifier.size(iconSize) },
tint = MaterialTheme.colors.primary
)
}
@Composable
fun MessageIcon(modifier: Modifier) {
Icon(
painter = painterResource(R.drawable.ic_dm),
null,
modifier = modifier,
tint = MaterialTheme.colors.primary
)
}
@Composable
fun DownloadForOfflineIcon(iconSize: Dp) {
Icon(
imageVector = Icons.Default.DownloadForOffline,
null,
modifier = remember(iconSize) { Modifier.size(iconSize) },
tint = MaterialTheme.colors.primary
)
}
@Composable
fun HashCheckIcon(iconSize: Dp) {
Icon(
painter = painterResource(R.drawable.original),
contentDescription = stringResource(id = R.string.hash_verification_passed),
modifier = remember(iconSize) { Modifier.size(iconSize) },
tint = Color.Unspecified
)
}
@Composable
fun HashCheckFailedIcon(iconSize: Dp) {
Icon(
imageVector = Icons.Default.Report,
contentDescription = stringResource(id = R.string.hash_verification_failed),
modifier = remember(iconSize) { Modifier.size(iconSize) },
tint = Color.Red
)
}
@Composable
fun LikedIcon(iconSize: Dp) {
LikedIcon(modifier = remember(iconSize) { Modifier.size(iconSize) })
}
@Composable
fun LikedIcon(modifier: Modifier) {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = modifier,
tint = Color.Unspecified
)
}
@Composable
fun LikeIcon(iconSize: Dp, grayTint: Color) {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = remember(iconSize) { Modifier.size(iconSize) },
tint = grayTint
)
}
@Composable
fun RepostedIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = modifier,
tint = tint
)
}
@Composable
fun LightningAddressIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.lightning_address),
tint = tint,
modifier = modifier
)
}
@Composable
fun ZappedIcon(iconSize: Dp) {
ZappedIcon(modifier = remember(iconSize) { Modifier.size(iconSize) })
}
@Composable
fun ZappedIcon(modifier: Modifier) {
ZapIcon(modifier = modifier, BitcoinOrange)
}
@Composable
fun ZapIcon(iconSize: Dp, tint: Color = Color.Unspecified) {
ZapIcon(modifier = remember(iconSize) { Modifier.size(iconSize) }, tint)
}
@Composable
fun ZapIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
tint = tint,
modifier = modifier
)
}
@Composable
fun ExpandLessIcon(modifier: Modifier) {
Icon(
imageVector = Icons.Default.ExpandLess,
null,
modifier = modifier,
tint = MaterialTheme.colors.subtleButton
)
}
@Composable
fun ExpandMoreIcon(modifier: Modifier) {
Icon(
imageVector = Icons.Default.ExpandMore,
null,
modifier = modifier,
tint = MaterialTheme.colors.subtleButton
)
}
@Composable
fun CommentIcon(iconSize: Dp, tint: Color) {
Icon(
painter = painterResource(R.drawable.ic_comment),
contentDescription = null,
modifier = remember { Modifier.size(iconSize) },
tint = tint
)
}
@Composable
fun ViewCountIcon(iconSize: Dp, tint: Color = Color.Unspecified) {
ViewCountIcon(remember { Modifier.size(iconSize) }, tint)
}
@Composable
fun ViewCountIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
Icon(
imageVector = Icons.Outlined.BarChart,
null,
modifier = modifier,
tint = tint
)
}
@Composable
fun PollIcon() {
Icon(
painter = painterResource(R.drawable.ic_poll),
null,
modifier = Size20Modifier,
tint = MaterialTheme.colors.onBackground
)
}
@Composable
fun RegularPostIcon() {
Icon(
painter = painterResource(R.drawable.ic_lists),
null,
modifier = Size20Modifier,
tint = MaterialTheme.colors.onBackground
)
}
@Composable
fun CancelIcon() {
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Size30Modifier,
tint = MaterialTheme.colors.placeholderText
)
}
@Composable
fun CloseIcon() {
Icon(
painter = painterResource(id = R.drawable.ic_close),
contentDescription = stringResource(id = R.string.cancel),
modifier = Size20Modifier,
tint = Color.White
)
}
@Composable
fun MutedIcon() {
Icon(
imageVector = Icons.Default.VolumeOff,
contentDescription = stringResource(id = R.string.muted_button),
tint = MaterialTheme.colors.onBackground,
modifier = Size30Modifier
)
}
@Composable
fun MuteIcon() {
Icon(
imageVector = Icons.Default.VolumeUp,
contentDescription = stringResource(id = R.string.mute_button),
tint = MaterialTheme.colors.onBackground,
modifier = Size30Modifier
)
}
@Composable
fun SearchIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
Icon(
painter = painterResource(R.drawable.ic_search),
contentDescription = stringResource(id = R.string.search_button),
modifier = modifier,
tint = tint
)
}
@Composable
fun PlayIcon(modifier: Modifier, tint: Color) {
Icon(
imageVector = Icons.Outlined.PlayCircle,
contentDescription = null,
modifier = modifier,
tint = tint
)
}
@Composable
fun PinIcon(modifier: Modifier, tint: Color) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = null,
modifier = modifier,
tint = tint
)
}
@Composable
fun LyricsIcon(modifier: Modifier, tint: Color) {
Icon(
painter = painterResource(id = R.drawable.lyrics_on),
contentDescription = null,
modifier = modifier,
tint = tint
)
}
@Composable
fun LyricsOffIcon(modifier: Modifier, tint: Color) {
Icon(
painter = painterResource(id = R.drawable.lyrics_off),
contentDescription = null,
modifier = modifier,
tint = tint
)
}
@Composable
fun ClearTextIcon() {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = stringResource(R.string.clear)
)
}
@Composable
fun LinkIcon(modifier: Modifier, tint: Color) {
Icon(
imageVector = Icons.Default.Link,
contentDescription = stringResource(R.string.website),
modifier = modifier,
tint = tint
)
}
@Composable
fun VerticalDotsIcon() {
Icon(
imageVector = Icons.Default.MoreVert,
null,
modifier = Size18Modifier,
tint = MaterialTheme.colors.placeholderText
)
}
@Composable
fun NIP05CheckingIcon(modifier: Modifier) {
Icon(
imageVector = Icons.Default.Downloading,
contentDescription = stringResource(id = R.string.nip05_checking),
modifier = modifier,
tint = Color.Yellow
)
}
@Composable
fun NIP05VerifiedIcon(modifier: Modifier) {
Icon(
painter = painterResource(R.drawable.nip_05),
contentDescription = stringResource(id = R.string.nip05_verified),
modifier = modifier,
tint = Color.Unspecified
)
}
@Composable
fun NIP05FailedVerification(modifier: Modifier) {
Icon(
imageVector = Icons.Default.Report,
contentDescription = stringResource(id = R.string.nip05_failed),
modifier = modifier,
tint = Color.Red
)
}

View File

@@ -10,7 +10,6 @@ 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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -24,9 +23,7 @@ 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.res.painterResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
@@ -35,7 +32,7 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String, showHidden: Boolean = false, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val baseNote = remember { messageSetCard.note }
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -94,7 +91,21 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
Column(columnModifier) {
Row(Modifier.fillMaxWidth()) {
MessageIcon()
Box(
modifier = remember {
Modifier
.width(55.dp)
.padding(top = 5.dp, end = 5.dp)
}
) {
MessageIcon(
remember {
Modifier
.size(16.dp)
.align(Alignment.TopEnd)
}
)
}
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
NoteCompose(
@@ -102,6 +113,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
routeForLastRead = null,
isBoostedNote = true,
addMarginTop = false,
showHidden = showHidden,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
@@ -112,25 +124,3 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
}
}
}
@Composable
private fun MessageIcon() {
Box(
modifier = remember {
Modifier
.width(55.dp)
.padding(top = 5.dp, end = 5.dp)
}
) {
Icon(
painter = painterResource(R.drawable.ic_dm),
null,
modifier = remember {
Modifier
.size(16.dp)
.align(Alignment.TopEnd)
},
tint = MaterialTheme.colors.primary
)
}
}

View File

@@ -17,11 +17,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
@@ -32,14 +29,12 @@ 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
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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
@@ -62,11 +57,11 @@ import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size18dp
import com.vitorpamplona.amethyst.ui.theme.Size19dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
@@ -86,7 +81,7 @@ import kotlin.time.measureTimedValue
@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class)
@Composable
fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, showHidden: Boolean = false, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val baseNote = remember { multiSetCard.note }
val popupExpanded = remember { mutableStateOf(false) }
@@ -154,6 +149,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
routeForLastRead = null,
modifier = remember { Modifier.padding(top = 5.dp) },
isBoostedNote = true,
showHidden = showHidden,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
@@ -238,13 +234,7 @@ fun RenderLikeGallery(
)
} else {
when (val shortReaction = reactionType) {
"+" -> Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = modifier.size(Size18dp),
tint = Color.Unspecified
)
"+" -> LikedIcon(modifier.size(Size18dp))
"-" -> Text(text = "\uD83D\uDC4E", modifier = modifier)
else -> Text(text = shortReaction, modifier = modifier)
}
@@ -264,30 +254,20 @@ fun RenderZapGallery(
accountViewModel: AccountViewModel
) {
Row(Modifier.fillMaxWidth()) {
ZapIcon()
Box(
modifier = WidthAuthorPictureModifier
) {
ZappedIcon(
modifier = remember {
Modifier.size(Size25dp).align(Alignment.TopEnd)
}
)
}
AuthorGalleryZaps(zapEvents, backgroundColor, nav, accountViewModel)
}
}
@Composable
private fun ZapIcon() {
Box(
modifier = WidthAuthorPictureModifier
) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = "Zaps",
tint = BitcoinOrange,
modifier =
Modifier
.size(Size25dp)
.align(Alignment.TopEnd)
)
}
}
@Composable
fun RenderBoostGallery(
boostEvents: ImmutableList<Note>,
@@ -300,15 +280,8 @@ fun RenderBoostGallery(
Box(
modifier = NotificationIconModifierSmaller
) {
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = remember {
Modifier
.size(19.dp)
.align(Alignment.TopEnd)
},
tint = Color.Unspecified
RepostedIcon(
modifier = remember { Modifier.size(Size19dp).align(Alignment.TopEnd) }
)
}
@@ -562,7 +535,7 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture(
}
}
WatchFollows(author.pubkeyHex, accountViewModel) { isFollowing ->
WatchUserFollows(author.pubkeyHex, accountViewModel) { isFollowing ->
Crossfade(targetState = isFollowing) {
if (it) {
Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) {

View File

@@ -6,13 +6,9 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Downloading
import androidx.compose.material.icons.filled.Report
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -23,21 +19,22 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserMetadata
import com.vitorpamplona.amethyst.service.Nip05Verifier
import com.vitorpamplona.amethyst.ui.theme.Nip05
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.note.NIP05CheckingIcon
import com.vitorpamplona.amethyst.ui.note.NIP05FailedVerification
import com.vitorpamplona.amethyst.ui.note.NIP05VerifiedIcon
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.nip05
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -152,52 +149,31 @@ private fun DisplayNIP05(
if (user != "_") {
Text(
text = remember(nip05) { AnnotatedString(user) },
color = MaterialTheme.colors.placeholderText,
color = MaterialTheme.colors.nip05,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
NIP05VerifiedSymbol(nip05Verified)
NIP05VerifiedSymbol(nip05Verified, NIP05IconSize)
ClickableText(
text = remember(nip05) { AnnotatedString(domain) },
onClick = { runCatching { uri.openUri("https://$domain") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.nip05),
maxLines = 1,
overflow = TextOverflow.Visible
)
}
@Composable
private fun NIP05VerifiedSymbol(nip05Verified: MutableState<Boolean?>) {
if (nip05Verified.value == null) {
Icon(
tint = Color.Yellow,
imageVector = Icons.Default.Downloading,
contentDescription = "Downloading",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else if (nip05Verified.value == true) {
Icon(
painter = painterResource(R.drawable.ic_verified_transparent),
"Nostr Address Verified",
tint = Nip05.copy(0.52f),
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Nostr Address",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
private fun NIP05VerifiedSymbol(nip05Verified: MutableState<Boolean?>, modifier: Modifier) {
Crossfade(targetState = nip05Verified.value) {
when (it) {
null -> NIP05CheckingIcon(modifier = modifier)
true -> NIP05VerifiedIcon(modifier = modifier)
false -> NIP05FailedVerification(modifier = modifier)
}
}
}
@@ -207,31 +183,9 @@ fun DisplayNip05ProfileStatus(user: User) {
user.nip05()?.let { nip05 ->
if (nip05.split("@").size <= 2) {
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05Verified == null) {
Icon(
tint = Color.Yellow,
imageVector = Icons.Default.Downloading,
contentDescription = "Downloading",
modifier = Modifier.size(16.dp)
)
} else if (nip05Verified == true) {
Icon(
painter = painterResource(R.drawable.ic_verified_transparent),
"NIP-05 Verified",
tint = Nip05,
modifier = Modifier.size(16.dp)
)
} else {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Nip05",
modifier = Modifier.size(16.dp)
)
}
NIP05VerifiedSymbol(nip05Verified, Size16Modifier)
var domainPadStart = 5.dp
val (user, domain) = remember(nip05) {
@@ -246,6 +200,7 @@ fun DisplayNip05ProfileStatus(user: User) {
if (user != "_") {
Text(
text = remember { AnnotatedString(user + "@") },
color = MaterialTheme.colors.nip05,
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -256,7 +211,7 @@ fun DisplayNip05ProfileStatus(user: User) {
ClickableText(
text = AnnotatedString(domain),
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.nip05),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = domainPadStart),
maxLines = 1,
overflow = TextOverflow.Ellipsis

View File

@@ -29,17 +29,11 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.darkColors
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
@@ -153,7 +147,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.JoinCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
@@ -164,6 +157,8 @@ import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
@@ -175,7 +170,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
@@ -214,6 +208,7 @@ fun NoteCompose(
unPackReply: Boolean = true,
makeItShort: Boolean = false,
addMarginTop: Boolean = true,
showHidden: Boolean = false,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -237,17 +232,18 @@ fun NoteCompose(
}
} else {
CheckHiddenNoteCompose(
baseNote,
routeForLastRead,
modifier,
isBoostedNote,
isQuotedNote,
unPackReply,
makeItShort,
addMarginTop,
parentBackgroundColor,
accountViewModel,
nav
note = baseNote,
routeForLastRead = routeForLastRead,
modifier = modifier,
isBoostedNote = isBoostedNote,
isQuotedNote = isQuotedNote,
unPackReply = unPackReply,
makeItShort = makeItShort,
addMarginTop = addMarginTop,
showHidden = showHidden,
parentBackgroundColor = parentBackgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
}
@@ -263,30 +259,59 @@ fun CheckHiddenNoteCompose(
unPackReply: Boolean = true,
makeItShort: Boolean = false,
addMarginTop: Boolean = true,
showHidden: Boolean = false,
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) {
LoadedNoteCompose(
note,
routeForLastRead,
modifier,
isBoostedNote,
isQuotedNote,
unPackReply,
makeItShort,
addMarginTop,
parentBackgroundColor,
accountViewModel,
nav
if (showHidden) {
// Ignores reports as well
val state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
)
}
RenderReportState(
state = state,
note = note,
routeForLastRead = routeForLastRead,
modifier = modifier,
isBoostedNote = isBoostedNote,
isQuotedNote = isQuotedNote,
unPackReply = unPackReply,
makeItShort = makeItShort,
addMarginTop = addMarginTop,
parentBackgroundColor = parentBackgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
} else {
val isHidden by accountViewModel.account.liveHiddenUsers.map {
note.isHiddenFor(it)
}.observeAsState(accountViewModel.isNoteHidden(note))
Crossfade(targetState = isHidden) {
if (!it) {
LoadedNoteCompose(
note = note,
routeForLastRead = routeForLastRead,
modifier = modifier,
isBoostedNote = isBoostedNote,
isQuotedNote = isQuotedNote,
unPackReply = unPackReply,
makeItShort = makeItShort,
addMarginTop = addMarginTop,
parentBackgroundColor = parentBackgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
}
@@ -438,13 +463,15 @@ fun NormalNote(
accountViewModel = accountViewModel,
nav = nav
)
is CommunityDefinitionEvent -> CommunityHeader(
baseNote = baseNote,
showBottomDiviser = true,
sendToCommunity = true,
accountViewModel = accountViewModel,
nav = nav
)
is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let {
CommunityHeader(
baseNote = it,
showBottomDiviser = true,
sendToCommunity = true,
accountViewModel = accountViewModel,
nav = nav
)
}
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
is FileHeaderEvent -> FileHeaderDisplay(baseNote, accountViewModel)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, accountViewModel)
@@ -471,14 +498,14 @@ fun NormalNote(
@Composable
fun CommunityHeader(
baseNote: Note,
baseNote: AddressableNote,
showBottomDiviser: Boolean,
sendToCommunity: Boolean,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var expanded = remember { mutableStateOf(false) }
val expanded = remember { mutableStateOf(false) }
Column(
modifier = modifier.clickable {
@@ -506,7 +533,7 @@ fun CommunityHeader(
}
@Composable
fun LongCommunityHeader(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun LongCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteState by baseNote.live().metadata.observeAsState()
val noteEvent = remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return
@@ -648,7 +675,7 @@ fun LongCommunityHeader(baseNote: Note, accountViewModel: AccountViewModel, nav:
}
@Composable
fun ShortCommunityHeader(baseNote: Note, expanded: MutableState<Boolean>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun ShortCommunityHeader(baseNote: AddressableNote, expanded: MutableState<Boolean>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteState by baseNote.live().metadata.observeAsState()
val noteEvent = remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return
@@ -713,44 +740,45 @@ fun ShortCommunityHeader(baseNote: Note, expanded: MutableState<Boolean>, accoun
@Composable
private fun ShortCommunityActionOptions(
note: Note,
note: AddressableNote,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val isFollowing by remember(accountState) {
derivedStateOf {
accountState?.account?.followingCommunities?.contains(note.idHex) ?: false
}
}
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
if (!isFollowing) {
Spacer(modifier = StdHorzSpacer)
JoinCommunityButton(accountViewModel, note, nav)
WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
if (!isFollowing) {
Spacer(modifier = StdHorzSpacer)
JoinCommunityButton(accountViewModel, note, nav)
}
}
}
@Composable
fun WatchAddressableNoteFollows(note: AddressableNote, accountViewModel: AccountViewModel, onFollowChanges: @Composable (Boolean) -> Unit) {
val showFollowingMark by accountViewModel.userFollows.map {
it.user.latestContactList?.isTaggedAddressableNote(note.idHex) ?: false
}.distinctUntilChanged().observeAsState(
accountViewModel.userProfile().latestContactList?.isTaggedAddressableNote(note.idHex) ?: false
)
onFollowChanges(showFollowingMark)
}
@Composable
private fun LongCommunityActionOptions(
note: Note,
note: AddressableNote,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val isFollowing by remember(accountState) {
derivedStateOf {
accountState?.account?.followingCommunities?.contains(note.idHex) ?: false
WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
if (isFollowing) {
LeaveCommunityButton(accountViewModel, note, nav)
}
}
if (isFollowing) {
LeaveCommunityButton(accountViewModel, note, nav)
}
}
@Composable
@@ -1318,7 +1346,11 @@ fun RenderAppDefinition(
)
if (zoomImageDialogOpen) {
ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel)
ZoomableImageDialog(
imageUrl = figureOutMimeType(it.banner!!),
onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel
)
}
} else {
Image(
@@ -1369,7 +1401,11 @@ fun RenderAppDefinition(
}
if (zoomImageDialogOpen) {
ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel)
ZoomableImageDialog(
imageUrl = figureOutMimeType(it.banner!!),
onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel
)
}
Spacer(Modifier.weight(1f))
@@ -1397,12 +1433,7 @@ fun RenderAppDefinition(
val website = remember(it) { it.website }
if (!website.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
tint = MaterialTheme.colors.placeholderText,
imageVector = Icons.Default.Link,
contentDescription = stringResource(R.string.website),
modifier = Modifier.size(16.dp)
)
LinkIcon(Size16Modifier, MaterialTheme.colors.placeholderText)
ClickableText(
text = AnnotatedString(website.removePrefix("https://")),
@@ -1460,15 +1491,15 @@ private fun RenderHighlight(
}
DisplayHighlight(
quote,
author,
url,
postHex,
makeItShort,
canPreview,
backgroundColor,
accountViewModel,
nav
highlight = quote,
authorHex = author,
url = url,
postAddress = postHex,
makeItShort = makeItShort,
canPreview = canPreview,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
@@ -2148,12 +2179,7 @@ fun RenderPinListEvent(
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
pinsToShow.forEach { pin ->
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = null,
tint = MaterialTheme.colors.onBackground.copy(0.32f),
modifier = Modifier.size(15.dp)
)
PinIcon(modifier = Size15Modifier, tint = MaterialTheme.colors.onBackground.copy(0.32f))
Spacer(modifier = Modifier.width(5.dp))
@@ -2203,7 +2229,7 @@ private fun RenderAudioTrack(
) {
val noteEvent = note.event as? AudioTrackEvent ?: return
AudioTrackHeader(noteEvent, accountViewModel, nav)
AudioTrackHeader(noteEvent, note, accountViewModel, nav)
}
@Composable
@@ -2438,7 +2464,7 @@ private fun BoostedMark() {
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
modifier = StdStartPadding
modifier = HalfStartPadding
)
}
@@ -2466,16 +2492,6 @@ fun MoreOptionsButton(
}
}
@Composable
private fun VerticalDotsIcon() {
Icon(
imageVector = Icons.Default.MoreVert,
null,
modifier = Size15Modifier,
tint = MaterialTheme.colors.placeholderText
)
}
@Composable
fun TimeAgo(note: Note) {
val time = remember(note) { note.createdAt() } ?: return
@@ -2811,7 +2827,7 @@ fun DisplayFollowingHashtagsInPost(
}
firstTag?.let {
Column() {
Column(verticalArrangement = Arrangement.Center) {
Row(verticalAlignment = CenterVertically) {
DisplayTagList(it, nav)
}
@@ -2991,19 +3007,9 @@ private fun RenderPledgeAmount(
}
if (hasPledge) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(20.dp),
tint = BitcoinOrange
)
ZappedIcon(modifier = Size20Modifier)
} else {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.placeholderText
)
ZapIcon(modifier = Size20Modifier, MaterialTheme.colors.placeholderText)
}
Text(
@@ -3133,11 +3139,25 @@ fun FileHeaderDisplay(note: Note, accountViewModel: AccountViewModel) {
val description = event.content
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val uri = "nostr:" + note.toNEvent()
val uri = note.toNostrUri()
val newContent = if (isImage) {
ZoomableUrlImage(fullUrl, description, hash, blurHash, dimensions, uri)
ZoomableUrlImage(
url = fullUrl,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri
)
} else {
ZoomableUrlVideo(fullUrl, description, hash, uri)
ZoomableUrlVideo(
url = fullUrl,
description = description,
hash = hash,
dim = dimensions,
uri = uri,
authorName = note.author?.toBestDisplayName()
)
}
launch(Dispatchers.Main) {
@@ -3197,7 +3217,7 @@ private fun RenderNIP95(
if (content == null) {
LaunchedEffect(key1 = eventHeader.id, key2 = noteState, key3 = note?.event) {
launch(Dispatchers.IO) {
val uri = "nostr:" + header.toNEvent()
val uri = header.toNostrUri()
val localDir =
note?.idHex?.let { File(File(appContext.externalCacheDir, "NIP95"), it) }
val blurHash = eventHeader.blurhash()
@@ -3222,7 +3242,8 @@ private fun RenderNIP95(
description = description,
dim = dimensions,
isVerified = true,
uri = uri
uri = uri,
authorName = header.author?.toBestDisplayName()
)
}
@@ -3243,7 +3264,7 @@ private fun RenderNIP95(
}
@Composable
fun AudioTrackHeader(noteEvent: AudioTrackEvent, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val media = remember { noteEvent.media() }
val cover = remember { noteEvent.cover() }
val subject = remember { noteEvent.subject() }
@@ -3305,14 +3326,17 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, accountViewModel: AccountViewMo
cover?.let { cover ->
LoadThumbAndThenVideoView(
videoUri = media,
description = noteEvent.subject(),
title = noteEvent.subject(),
thumbUri = cover,
authorName = note.author?.toBestDisplayName(),
nostrUriCallback = "nostr:${note.toNEvent()}",
accountViewModel = accountViewModel
)
}
?: VideoView(
videoUri = media,
description = noteEvent.subject(),
title = noteEvent.subject(),
authorName = note.author?.toBestDisplayName(),
accountViewModel = accountViewModel
)
}
@@ -3437,8 +3461,11 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
) {
VideoView(
videoUri = media,
description = subject,
accountViewModel = accountViewModel
title = subject,
artworkUri = cover,
authorName = baseNote.author?.toBestDisplayName(),
accountViewModel = accountViewModel,
nostrUriCallback = "nostr:${baseNote.toNEvent()}"
)
}
} else {

View File

@@ -38,12 +38,6 @@ import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ProgressIndicatorDefaults
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -88,7 +82,6 @@ import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.TextType
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DarkerGreen
import com.vitorpamplona.amethyst.ui.theme.Font14SP
@@ -112,7 +105,6 @@ import com.vitorpamplona.amethyst.ui.theme.TinyBorders
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.placeholderTextColorFilter
import com.vitorpamplona.amethyst.ui.theme.subtleButton
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope
@@ -363,6 +355,20 @@ private fun WatchReactionsZapsBoostsAndDisplayIfExists(baseNote: Note, content:
}
}
fun <T, K, R> LiveData<T>.combineWith(
liveData1: LiveData<K>,
block: (T?, K?) -> R
): LiveData<R> {
val result = MediatorLiveData<R>()
result.addSource(this) {
result.value = block(this.value, liveData1.value)
}
result.addSource(liveData1) {
result.value = block(this.value, liveData1.value)
}
return result
}
fun <T, K, P, R> LiveData<T>.combineWith(
liveData1: LiveData<K>,
liveData2: LiveData<P>,
@@ -391,19 +397,9 @@ private fun RenderShowIndividualReactionsButton(wantsToSeeReactions: MutableStat
) {
Crossfade(targetState = wantsToSeeReactions.value) {
if (it) {
Icon(
imageVector = Icons.Default.ExpandLess,
null,
modifier = Size22Modifier,
tint = MaterialTheme.colors.subtleButton
)
ExpandLessIcon(modifier = Size22Modifier)
} else {
Icon(
imageVector = Icons.Default.ExpandMore,
null,
modifier = Size22Modifier,
tint = MaterialTheme.colors.subtleButton
)
ExpandMoreIcon(modifier = Size22Modifier)
}
}
}
@@ -544,12 +540,7 @@ fun ReplyReaction(
}
}
) {
Icon(
painter = painterResource(R.drawable.ic_comment),
contentDescription = null,
modifier = remember { Modifier.size(iconSize) },
tint = grayTint
)
CommentIcon(iconSize, grayTint)
}
if (showCounter) {
@@ -690,12 +681,7 @@ fun BoostIcon(baseNote: Note, iconSize: Dp = Size20dp, grayTint: Color, accountV
Modifier.size(iconSize)
}
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = iconModifier,
tint = iconTint
)
RepostedIcon(iconModifier, iconTint)
}
@Composable
@@ -805,7 +791,7 @@ fun LikeIcon(
if (value != null) {
RenderReactionType(value, iconSize, iconFontSize)
} else {
RenderLikeIcon(iconSize, grayTint)
LikeIcon(iconSize, grayTint)
}
}
}
@@ -822,33 +808,12 @@ private fun WatchReactionTypeForNote(baseNote: Note, accountViewModel: AccountVi
}
}
@Composable
private fun RenderLikeIcon(
iconSize: Dp = Size20dp,
grayTint: Color
) {
val iconModifier = remember {
Modifier.size(iconSize)
}
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = iconModifier,
tint = grayTint
)
}
@Composable
private fun RenderReactionType(
reactionType: String,
iconSize: Dp = Size20dp,
iconFontSize: TextUnit
) {
val iconModifier = remember {
Modifier.size(iconSize)
}
if (reactionType.startsWith(":")) {
val noStartColon = reactionType.removePrefix(":")
val url = noStartColon.substringAfter(":")
@@ -865,15 +830,7 @@ private fun RenderReactionType(
)
} else {
when (reactionType) {
"+" -> {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = iconModifier,
tint = Color.Unspecified
)
}
"+" -> LikedIcon(iconSize)
"-" -> Text(text = "\uD83D\uDC4E", fontSize = iconFontSize)
else -> Text(text = reactionType, fontSize = iconFontSize)
}
@@ -1137,19 +1094,9 @@ private fun ZapIcon(
Crossfade(targetState = wasZappedByLoggedInUser) {
if (it.value) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = remember { Modifier.size(iconSize) },
tint = BitcoinOrange
)
ZappedIcon(iconSize)
} else {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = remember { Modifier.size(iconSize) },
tint = grayTint
)
ZapIcon(iconSize, grayTint)
}
}
}
@@ -1205,27 +1152,10 @@ fun ViewCountReaction(
numberSize: Dp = Size24dp,
viewCountColorFilter: ColorFilter
) {
DrawViewCountIcon(barChartSize, grayTint)
ViewCountIcon(barChartSize, grayTint)
DrawViewCount(note, numberSize, viewCountColorFilter)
}
@Composable
private fun DrawViewCountIcon(
barChartSize: Dp = Size19dp,
grayTint: Color
) {
val iconButtonModifier = remember {
Modifier.size(barChartSize)
}
Icon(
imageVector = Icons.Outlined.BarChart,
null,
modifier = iconButtonModifier,
tint = grayTint
)
}
@Composable
private fun DrawViewCount(
note: Note,

View File

@@ -22,6 +22,8 @@ fun timeAgo(mills: Long?, context: Context): String {
.replace(" hr. ago", context.getString(R.string.h))
.replace(" min. ago", context.getString(R.string.m))
.replace(" days ago", context.getString(R.string.d))
.replace(" hr ago", context.getString(R.string.h))
.replace(" min ago", context.getString(R.string.m))
.replace("Yesterday", "1" + context.getString(R.string.d))
}

View File

@@ -13,7 +13,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.ripple.rememberRipple
@@ -31,11 +30,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
@@ -264,7 +261,7 @@ fun PictureAndFollowingMark(
@Composable
fun ObserveAndDisplayFollowingMark(userHex: String, iconSize: Dp, accountViewModel: AccountViewModel) {
WatchFollows(userHex, accountViewModel) { newFollowingState ->
WatchUserFollows(userHex, accountViewModel) { newFollowingState ->
Crossfade(targetState = newFollowingState) { following ->
if (following) {
Box(contentAlignment = Alignment.TopEnd) {
@@ -276,7 +273,7 @@ fun ObserveAndDisplayFollowingMark(userHex: String, iconSize: Dp, accountViewMod
}
@Composable
fun WatchFollows(userHex: String, accountViewModel: AccountViewModel, onFollowChanges: @Composable (Boolean) -> Unit) {
fun WatchUserFollows(userHex: String, accountViewModel: AccountViewModel, onFollowChanges: @Composable (Boolean) -> Unit) {
val showFollowingMark by accountViewModel.userFollows.map {
it.user.isFollowingCached(userHex) || (userHex == accountViewModel.account.userProfile().pubkeyHex)
}.distinctUntilChanged().observeAsState(
@@ -286,20 +283,6 @@ fun WatchFollows(userHex: String, accountViewModel: AccountViewModel, onFollowCh
onFollowChanges(showFollowingMark)
}
@Composable
fun FollowingIcon(iconSize: Dp) {
val modifier = remember {
Modifier.size(iconSize)
}
Icon(
painter = painterResource(R.drawable.verified_follow_shield),
contentDescription = stringResource(id = R.string.following),
modifier = modifier,
tint = Color.Unspecified
)
}
@Immutable
data class DropDownParams(
val isFollowingAuthor: Boolean,

View File

@@ -5,12 +5,9 @@ import android.util.Log
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -163,16 +160,8 @@ fun DrawPlayName(name: String) {
@Composable
fun DrawPlayNameIcon(onClick: () -> Unit) {
IconButton(
onClick = onClick,
modifier = StdButtonSizeModifier
) {
Icon(
imageVector = Icons.Outlined.PlayCircle,
contentDescription = null,
modifier = StdButtonSizeModifier,
tint = MaterialTheme.colors.placeholderText
)
IconButton(onClick = onClick, modifier = StdButtonSizeModifier) {
PlayIcon(modifier = StdButtonSizeModifier, tint = MaterialTheme.colors.placeholderText)
}
}

View File

@@ -174,7 +174,9 @@ fun UserActionOptions(
) {
val scope = rememberCoroutineScope()
val accountState by accountViewModel.accountLiveData.observeAsState()
val isHidden by remember(accountState) {
val blockList by accountViewModel.account.getBlockListNote().live().metadata.observeAsState()
val isHidden by remember(accountState, blockList) {
derivedStateOf {
accountState?.account?.isHidden(baseAuthor) ?: false
}

View File

@@ -7,11 +7,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.derivedStateOf
@@ -23,12 +19,11 @@ 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.res.stringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.ZapUserSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -75,17 +70,14 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
// Draws the like picture outside the boosted card.
if (!isInnerNote) {
Box(
modifier = Modifier
.width(55.dp)
.padding(0.dp)
modifier = Size55Modifier
) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(id = R.string.zaps),
tint = BitcoinOrange,
modifier = Modifier
.size(25.dp)
.align(Alignment.TopEnd)
ZappedIcon(
remember {
Modifier
.size(Size25dp)
.align(Alignment.TopEnd)
}
)
}
}

View File

@@ -7,12 +7,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
@Composable
fun AccountScreen(accountStateViewModel: AccountStateViewModel, themeViewModel: ThemeViewModel, startingPage: String?) {
fun AccountScreen(
accountStateViewModel: AccountStateViewModel,
themeViewModel: ThemeViewModel,
navController: NavHostController
) {
val accountState by accountStateViewModel.accountContent.collectAsState()
Column() {
@@ -27,7 +32,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, themeViewModel:
factory = AccountViewModel.Factory(state.account)
)
MainScreen(accountViewModel, accountStateViewModel, themeViewModel, startingPage)
MainScreen(accountViewModel, accountStateViewModel, themeViewModel, navController)
}
is AccountState.LoggedInViewOnly -> {
val accountViewModel: AccountViewModel = viewModel(
@@ -35,7 +40,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, themeViewModel:
factory = AccountViewModel.Factory(state.account)
)
MainScreen(accountViewModel, accountStateViewModel, themeViewModel, startingPage)
MainScreen(accountViewModel, accountStateViewModel, themeViewModel, navController)
}
}
}

View File

@@ -90,7 +90,7 @@ sealed class CardFeedState {
object Loading : CardFeedState()
@Stable
class Loaded(val feed: MutableState<ImmutableList<Card>>) : CardFeedState()
class Loaded(val feed: MutableState<ImmutableList<Card>>, val showHidden: MutableState<Boolean>) : CardFeedState()
@Immutable
object Empty : CardFeedState()

View File

@@ -141,9 +141,9 @@ fun RenderCardFeed(
FeedLoaded(
state = state,
listState = listState,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
nav = nav
)
}
CardFeedState.Loading -> {
@@ -158,9 +158,9 @@ fun RenderCardFeed(
private fun FeedLoaded(
state: CardFeedState.Loaded,
listState: LazyListState,
routeForLastRead: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String
nav: (String) -> Unit
) {
LazyColumn(
modifier = remember { Modifier.fillMaxSize() },
@@ -179,7 +179,7 @@ private fun FeedLoaded(
}
Row(defaultModifier) {
RenderCardItem(item, accountViewModel, nav, routeForLastRead)
RenderCardItem(item, routeForLastRead, showHidden = state.showHidden.value, accountViewModel, nav)
}
}
}
@@ -188,15 +188,17 @@ private fun FeedLoaded(
@Composable
private fun RenderCardItem(
item: Card,
routeForLastRead: String,
showHidden: Boolean,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String
nav: (String) -> Unit
) {
when (item) {
is NoteCard -> NoteCompose(
item,
isBoostedNote = false,
accountViewModel = accountViewModel,
showHidden = showHidden,
nav = nav,
routeForLastRead = routeForLastRead
)
@@ -212,6 +214,7 @@ private fun RenderCardItem(
is MultiSetCard -> MultiSetCompose(
item,
accountViewModel = accountViewModel,
showHidden = showHidden,
nav = nav,
routeForLastRead = routeForLastRead
)
@@ -219,6 +222,7 @@ private fun RenderCardItem(
is BadgeCard -> BadgeCompose(
item,
accountViewModel = accountViewModel,
showHidden = showHidden,
nav = nav,
routeForLastRead = routeForLastRead
)
@@ -226,6 +230,7 @@ private fun RenderCardItem(
is MessageSetCard -> MessageSetCompose(
messageSetCard = item,
routeForLastRead = routeForLastRead,
showHidden = showHidden,
accountViewModel = accountViewModel,
nav = nav
)
@@ -242,6 +247,7 @@ fun NoteCompose(
unPackReply: Boolean = true,
makeItShort: Boolean = false,
addMarginTop: Boolean = true,
showHidden: Boolean = false,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -259,6 +265,7 @@ fun NoteCompose(
unPackReply = unPackReply,
makeItShort = makeItShort,
addMarginTop = addMarginTop,
showHidden = showHidden,
parentBackgroundColor = parentBackgroundColor,
accountViewModel = accountViewModel,
nav = nav

View File

@@ -223,9 +223,10 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
if (notes.isEmpty()) {
_feedContent.update { CardFeedState.Empty }
} else if (currentState is CardFeedState.Loaded) {
currentState.showHidden.value = localFilter.showHiddenKey()
currentState.feed.value = notes
} else {
_feedContent.update { CardFeedState.Loaded(mutableStateOf(notes)) }
_feedContent.update { CardFeedState.Loaded(mutableStateOf(notes), mutableStateOf(localFilter.showHiddenKey())) }
}
}
}

View File

@@ -6,7 +6,7 @@ import kotlinx.collections.immutable.ImmutableList
sealed class FeedState {
object Loading : FeedState()
class Loaded(val feed: MutableState<ImmutableList<Note>>) : FeedState()
class Loaded(val feed: MutableState<ImmutableList<Note>>, val showHidden: MutableState<Boolean>) : FeedState()
object Empty : FeedState()
class FeedError(val errorMessage: String) : FeedState()
}

View File

@@ -148,11 +148,11 @@ private fun RenderFeed(
is FeedState.Loaded -> {
FeedLoaded(
state,
listState,
routeForLastRead,
accountViewModel,
nav
state = state,
listState = listState,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav
)
}
@@ -210,6 +210,7 @@ private fun FeedLoaded(
routeForLastRead = routeForLastRead,
modifier = baseModifier,
isBoostedNote = false,
showHidden = state.showHidden.value,
accountViewModel = accountViewModel,
nav = nav
)

View File

@@ -248,9 +248,10 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
_feedContent.update { FeedState.Empty }
} else if (currentState is FeedState.Loaded) {
// updates the current list
currentState.showHidden.value = localFilter.showHiddenKey()
currentState.feed.value = notes
} else {
_feedContent.update { FeedState.Loaded(mutableStateOf(notes)) }
_feedContent.update { FeedState.Loaded(mutableStateOf(notes), mutableStateOf(localFilter.showHiddenKey())) }
}
}
}

View File

@@ -1,9 +1,11 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Immutable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
@Immutable
class ThemeViewModel : ViewModel() {
private val _theme = MutableLiveData(0)
val theme: LiveData<Int> = _theme

View File

@@ -377,7 +377,7 @@ fun NoteMaster(
} else if (noteEvent is PeopleListEvent) {
DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is AudioTrackEvent) {
AudioTrackHeader(noteEvent, accountViewModel, nav)
AudioTrackHeader(noteEvent, baseNote, accountViewModel, nav)
} else if (noteEvent is CommunityPostApprovalEvent) {
RenderPostApproval(
baseNote,

View File

@@ -13,6 +13,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.HiddenAccountsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.SpammerAccountsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowsFeedFilter
import kotlinx.collections.immutable.ImmutableList
@@ -49,6 +50,14 @@ class NostrHiddenAccountsFeedViewModel(val account: Account) : UserFeedViewModel
}
}
class NostrSpammerAccountsFeedViewModel(val account: Account) : UserFeedViewModel(SpammerAccountsFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrSpammerAccountsFeedViewModel : ViewModel> create(modelClass: Class<NostrSpammerAccountsFeedViewModel>): NostrSpammerAccountsFeedViewModel {
return NostrSpammerAccountsFeedViewModel(account) as NostrSpammerAccountsFeedViewModel
}
}
}
@Stable
open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), InvalidatableViewModel {
private val _feedContent = MutableStateFlow<UserFeedState>(UserFeedState.Loading)

View File

@@ -80,6 +80,7 @@ import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
@@ -606,25 +607,47 @@ private fun ShowVideoStreaming(
event = it,
accountViewModel = accountViewModel
) {
val streamingUrl by baseChannel.live.map {
val streamingInfo by baseChannel.live.map {
val activity = it.channel as? LiveActivitiesChannel
activity?.info?.streaming()
}.distinctUntilChanged().observeAsState(baseChannel.info?.streaming())
activity?.info
}.distinctUntilChanged().observeAsState(baseChannel.info)
streamingUrl?.let {
CheckIfUrlIsOnline(it) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = remember { Modifier.heightIn(max = 300.dp) }
) {
val zoomableUrlVideo = remember(it) {
ZoomableUrlVideo(url = it)
streamingInfo?.let { event ->
val url = remember(streamingInfo) {
event.streaming()
}
val artworkUri = remember(streamingInfo) {
event.image()
}
val title = remember(streamingInfo) {
baseChannel.toBestDisplayName()
}
val author = remember(streamingInfo) {
baseChannel.creatorName()
}
url?.let {
CheckIfUrlIsOnline(url) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = remember { Modifier.heightIn(max = 300.dp) }
) {
val zoomableUrlVideo = remember(it) {
ZoomableUrlVideo(
url = url,
description = title,
artworkUri = artworkUri,
authorName = author,
uri = event.toNostrUri()
)
}
ZoomableContentView(
content = zoomableUrlVideo,
accountViewModel = accountViewModel
)
}
ZoomableContentView(
content = zoomableUrlVideo,
accountViewModel = accountViewModel
)
}
}
}
@@ -863,13 +886,6 @@ private fun ShortChannelActionOptions(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val isFollowing by remember(accountState) {
derivedStateOf {
accountState?.account?.followingChannels?.contains(channel.idHex) ?: false
}
}
LoadNote(baseNoteHex = channel.idHex) {
it?.let {
var popupExpanded by remember { mutableStateOf(false) }
@@ -882,11 +898,28 @@ private fun ShortChannelActionOptions(
}
}
if (!isFollowing) {
JoinChatButton(accountViewModel, channel, nav)
WatchChannelFollows(channel, accountViewModel) { isFollowing ->
if (!isFollowing) {
JoinChatButton(accountViewModel, channel, nav)
}
}
}
@Composable
private fun WatchChannelFollows(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
content: @Composable (Boolean) -> Unit
) {
val isFollowing by accountViewModel.userProfile().live().follows.map {
it.user.latestContactList?.isTaggedEvent(channel.idHex) ?: false
}.distinctUntilChanged().observeAsState(
accountViewModel.userProfile().latestContactList?.isTaggedEvent(channel.idHex) ?: false
)
content(isFollowing)
}
@Composable
private fun LongChannelActionOptions(
channel: PublicChatChannel,
@@ -899,19 +932,14 @@ private fun LongChannelActionOptions(
}
}
val accountState by accountViewModel.accountLiveData.observeAsState()
val isFollowing by remember(accountState) {
derivedStateOf {
accountState?.account?.followingChannels?.contains(channel.idHex) ?: false
}
}
if (isMe) {
EditButton(accountViewModel, channel)
}
if (isFollowing) {
LeaveChatButton(accountViewModel, channel, nav)
WatchChannelFollows(channel, accountViewModel) { isFollowing ->
if (isFollowing) {
LeaveChatButton(accountViewModel, channel, nav)
}
}
}
@@ -1074,10 +1102,14 @@ private fun EditButton(accountViewModel: AccountViewModel, channel: PublicChatCh
@Composable
fun JoinChatButton(accountViewModel: AccountViewModel, channel: Channel, nav: (String) -> Unit) {
val scope = rememberCoroutineScope()
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.account.joinChannel(channel.idHex)
scope.launch(Dispatchers.IO) {
accountViewModel.account.follow(channel)
}
},
shape = ButtonBorder,
colors = ButtonDefaults
@@ -1092,10 +1124,14 @@ fun JoinChatButton(accountViewModel: AccountViewModel, channel: Channel, nav: (S
@Composable
fun LeaveChatButton(accountViewModel: AccountViewModel, channel: Channel, nav: (String) -> Unit) {
val scope = rememberCoroutineScope()
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.account.leaveChannel(channel.idHex)
scope.launch(Dispatchers.IO) {
accountViewModel.account.unfollow(channel)
}
},
shape = ButtonBorder,
colors = ButtonDefaults
@@ -1109,11 +1145,15 @@ fun LeaveChatButton(accountViewModel: AccountViewModel, channel: Channel, nav: (
}
@Composable
fun JoinCommunityButton(accountViewModel: AccountViewModel, note: Note, nav: (String) -> Unit) {
fun JoinCommunityButton(accountViewModel: AccountViewModel, note: AddressableNote, nav: (String) -> Unit) {
val scope = rememberCoroutineScope()
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.account.joinCommunity(note.idHex)
scope.launch(Dispatchers.IO) {
accountViewModel.account.follow(note)
}
},
shape = ButtonBorder,
colors = ButtonDefaults
@@ -1127,11 +1167,15 @@ fun JoinCommunityButton(accountViewModel: AccountViewModel, note: Note, nav: (St
}
@Composable
fun LeaveCommunityButton(accountViewModel: AccountViewModel, note: Note, nav: (String) -> Unit) {
fun LeaveCommunityButton(accountViewModel: AccountViewModel, note: AddressableNote, nav: (String) -> Unit) {
val scope = rememberCoroutineScope()
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.account.leaveCommunity((note.idHex))
scope.launch(Dispatchers.IO) {
accountViewModel.account.unfollow(note)
}
},
shape = ButtonBorder,
colors = ButtonDefaults

View File

@@ -35,6 +35,9 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.ChannelCardCompose
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
@@ -92,9 +95,9 @@ fun DiscoverScreen(
val tabs by remember(discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel, discoveryChatFeedViewModel) {
mutableStateOf(
listOf(
TabItem(R.string.discover_live, discoveryLiveFeedViewModel, Route.Discover.base + "Live", ScrollStateKeys.DISCOVER_LIVE),
TabItem(R.string.discover_community, discoveryCommunityFeedViewModel, Route.Discover.base + "Community", ScrollStateKeys.DISCOVER_COMMUNITY),
TabItem(R.string.discover_chat, discoveryChatFeedViewModel, Route.Discover.base + "Chats", ScrollStateKeys.DISCOVER_CHATS)
TabItem(R.string.discover_live, discoveryLiveFeedViewModel, Route.Discover.base + "Live", ScrollStateKeys.DISCOVER_LIVE, LiveActivitiesEvent.kind),
TabItem(R.string.discover_community, discoveryCommunityFeedViewModel, Route.Discover.base + "Community", ScrollStateKeys.DISCOVER_COMMUNITY, CommunityDefinitionEvent.kind),
TabItem(R.string.discover_chat, discoveryChatFeedViewModel, Route.Discover.base + "Chats", ScrollStateKeys.DISCOVER_CHATS, ChannelCreateEvent.kind)
).toImmutableList()
)
}
@@ -139,7 +142,14 @@ private fun DiscoverPages(
HorizontalPager(pageCount = 3, state = pagerState) { page ->
RefresheableView(tabs[page].viewModel, true) {
SaveableFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState ->
RenderDiscoverFeed(tabs[page].viewModel, tabs[page].routeForLastRead, accountViewModel, listState, nav)
RenderDiscoverFeed(
viewModel = tabs[page].viewModel,
routeForLastRead = tabs[page].routeForLastRead,
forceEventKind = tabs[page].forceEventKind,
listState = listState,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@@ -149,8 +159,9 @@ private fun DiscoverPages(
private fun RenderDiscoverFeed(
viewModel: FeedViewModel,
routeForLastRead: String?,
accountViewModel: AccountViewModel,
forceEventKind: Int?,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val feedState by viewModel.feedContent.collectAsState()
@@ -177,6 +188,7 @@ private fun RenderDiscoverFeed(
state,
routeForLastRead,
listState,
forceEventKind,
accountViewModel,
nav
)
@@ -212,6 +224,7 @@ private fun DiscoverFeedLoaded(
state: FeedState.Loaded,
routeForLastRead: String?,
listState: LazyListState,
forceEventKind: Int?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -232,6 +245,7 @@ private fun DiscoverFeedLoaded(
baseNote = item,
routeForLastRead = routeForLastRead,
modifier = Modifier,
forceEventKind = forceEventKind,
accountViewModel = accountViewModel,
nav = nav
)

View File

@@ -32,23 +32,30 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrSpammerAccountsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.coroutines.launch
@Composable
fun HiddenUsersScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val feedViewModel: NostrHiddenAccountsFeedViewModel = viewModel(
val hiddenFeedViewModel: NostrHiddenAccountsFeedViewModel = viewModel(
factory = NostrHiddenAccountsFeedViewModel.Factory(accountViewModel.account)
)
HiddenUsersScreen(feedViewModel, accountViewModel, nav)
val spammerFeedViewModel: NostrSpammerAccountsFeedViewModel = viewModel(
factory = NostrSpammerAccountsFeedViewModel.Factory(accountViewModel.account)
)
HiddenUsersScreen(hiddenFeedViewModel, spammerFeedViewModel, accountViewModel, nav)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HiddenUsersScreen(
feedViewModel: NostrHiddenAccountsFeedViewModel,
hiddenFeedViewModel: NostrHiddenAccountsFeedViewModel,
spammerFeedViewModel: NostrSpammerAccountsFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -58,10 +65,8 @@ fun HiddenUsersScreen(
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Hidden Users Start")
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Hidden Users Stop")
hiddenFeedViewModel.invalidateData()
spammerFeedViewModel.invalidateData()
}
}
@@ -116,10 +121,18 @@ fun HiddenUsersScreen(
Text(text = stringResource(R.string.blocked_users))
}
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = {
Text(text = stringResource(R.string.spamming_users))
}
)
}
HorizontalPager(pageCount = 1, state = pagerState) { page ->
HorizontalPager(pageCount = 2, state = pagerState) { page ->
when (page) {
0 -> RefreshingUserFeedView(feedViewModel, accountViewModel, nav)
0 -> RefreshingUserFeedView(hiddenFeedViewModel, accountViewModel, nav)
1 -> RefreshingUserFeedView(spammerFeedViewModel, accountViewModel, nav)
}
}
}
@@ -128,22 +141,23 @@ fun HiddenUsersScreen(
@Composable
fun RefreshingUserFeedView(
feedViewModel: NostrHiddenAccountsFeedViewModel,
feedViewModel: UserFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
WatchAccount(feedViewModel, accountViewModel)
WatchAccountAndBlockList(feedViewModel, accountViewModel)
RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav)
}
@Composable
fun WatchAccount(
feedViewModel: NostrHiddenAccountsFeedViewModel,
fun WatchAccountAndBlockList(
feedViewModel: UserFeedViewModel,
accountViewModel: AccountViewModel
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val blockListState by accountViewModel.account.getBlockListNote().live().metadata.observeAsState()
LaunchedEffect(accountViewModel, accountState) {
LaunchedEffect(accountViewModel, accountState, blockListState) {
feedViewModel.invalidateData()
}
}

View File

@@ -175,5 +175,6 @@ class TabItem(
val resource: Int,
val viewModel: FeedViewModel,
val routeForLastRead: String,
val scrollStateKey: String
val scrollStateKey: String,
val forceEventKind: Int? = null
)

View File

@@ -27,8 +27,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.buttons.NewCommunityNoteButton
import com.vitorpamplona.amethyst.ui.buttons.NewImageButton
@@ -61,10 +61,9 @@ fun MainScreen(
accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel,
themeViewModel: ThemeViewModel,
startingPage: String? = null
navController: NavHostController
) {
val scope = rememberCoroutineScope()
val navController = rememberNavController()
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
val sheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
@@ -218,8 +217,7 @@ fun MainScreen(
userReactionsStatsModel = userReactionsStatsModel,
navController = navController,
accountViewModel = accountViewModel,
themeViewModel = themeViewModel,
nextPage = startingPage
themeViewModel = themeViewModel
)
}
}

View File

@@ -15,7 +15,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Link
@@ -81,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.components.figureOutMimeType
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.ShowQRDialog
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.LightningAddressIcon
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.FeedState
import com.vitorpamplona.amethyst.ui.screen.LnZapFeedView
@@ -97,8 +97,8 @@ import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView
import com.vitorpamplona.amethyst.ui.screen.RelayFeedView
import com.vitorpamplona.amethyst.ui.screen.RelayFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
@@ -701,45 +701,48 @@ private fun ProfileHeader(
private fun ProfileActions(
baseUser: User,
accountViewModel: AccountViewModel
) {
val isMe by remember(accountViewModel) {
derivedStateOf {
accountViewModel.userProfile() == baseUser
}
}
if (isMe) {
EditButton(accountViewModel.account)
}
WatchIsHiddenUser(baseUser, accountViewModel) { isHidden ->
if (isHidden) {
val scope = rememberCoroutineScope()
ShowUserButton {
scope.launch(Dispatchers.IO) {
accountViewModel.account.showUser(baseUser.pubkeyHex)
}
}
} else {
DisplayFollowUnfollowButton(baseUser, accountViewModel)
}
}
}
@Composable
private fun DisplayFollowUnfollowButton(
baseUser: User,
accountViewModel: AccountViewModel
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val accountLocalUserState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountLocalUserState) { accountLocalUserState?.account } ?: return
val isLoggedInFollowingUser by accountViewModel.account.userProfile().live().follows.map {
it.user.isFollowing(baseUser)
}.distinctUntilChanged().observeAsState(initial = accountViewModel.account.isFollowing(baseUser))
val accountUserState by accountViewModel.account.userProfile().live().follows.observeAsState()
val baseUserState by baseUser.live().follows.observeAsState()
val isUserFollowingLoggedIn by baseUser.live().follows.map {
it.user.isFollowing(accountViewModel.account.userProfile())
}.distinctUntilChanged().observeAsState(initial = baseUser.isFollowing(accountViewModel.account.userProfile()))
val accountUser = remember(accountUserState) { accountUserState?.user } ?: return
val isHidden by remember(accountUserState, accountLocalUserState) {
derivedStateOf {
account.isHidden(baseUser)
}
}
val isLoggedInFollowingUser by remember(accountUserState, accountLocalUserState) {
derivedStateOf {
accountUser.isFollowingCached(baseUser)
}
}
val isUserFollowingLoggedIn by remember(baseUserState, accountLocalUserState) {
derivedStateOf {
baseUser.isFollowing(accountUser)
}
}
if (accountUser == baseUser) {
EditButton(account)
}
if (isHidden) {
ShowUserButton {
account.showUser(baseUser.pubkeyHex)
}
} else if (isLoggedInFollowingUser) {
if (isLoggedInFollowingUser) {
UnfollowButton {
if (!accountViewModel.isWriteable()) {
scope.launch {
@@ -753,7 +756,7 @@ private fun ProfileActions(
}
} else {
scope.launch(Dispatchers.IO) {
account.unfollow(baseUser)
accountViewModel.account.unfollow(baseUser)
}
}
}
@@ -772,7 +775,7 @@ private fun ProfileActions(
}
} else {
scope.launch(Dispatchers.IO) {
account.follow(baseUser)
accountViewModel.account.follow(baseUser)
}
}
}
@@ -790,7 +793,7 @@ private fun ProfileActions(
}
} else {
scope.launch(Dispatchers.IO) {
account.follow(baseUser)
accountViewModel.account.follow(baseUser)
}
}
}
@@ -798,6 +801,15 @@ private fun ProfileActions(
}
}
@Composable
private fun WatchIsHiddenUser(baseUser: User, accountViewModel: AccountViewModel, content: @Composable (Boolean) -> Unit) {
val isHidden by accountViewModel.account.liveHiddenUsers.map {
it.hiddenUsers.contains(baseUser.pubkeyHex) || it.spammers.contains(baseUser.pubkeyHex)
}.observeAsState(accountViewModel.account.isHidden(baseUser))
content(isHidden)
}
@Composable
private fun DrawAdditionalInfo(
baseUser: User,
@@ -972,12 +984,7 @@ fun DisplayLNAddress(
if (!lud16.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
tint = BitcoinOrange,
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.lightning_address),
modifier = Modifier.size(16.dp)
)
LightningAddressIcon(modifier = Size16Modifier)
ClickableText(
text = AnnotatedString(lud16),
@@ -1638,6 +1645,7 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
val clipboardManager = LocalClipboardManager.current
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account!!
val blockList by accountViewModel.account.getBlockListNote().live().metadata.observeAsState()
val scope = rememberCoroutineScope()

View File

@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
@@ -18,14 +17,11 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -41,7 +37,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -62,10 +57,13 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.AboutDisplay
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
@@ -288,12 +286,7 @@ private fun SearchTextField(
capitalization = KeyboardCapitalization.Sentences
),
leadingIcon = {
Icon(
painter = painterResource(R.drawable.ic_search),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
SearchIcon(modifier = Size20Modifier, Color.Unspecified)
},
modifier = Modifier
.weight(1f, true)
@@ -312,10 +305,7 @@ private fun SearchTextField(
NostrSearchEventOrUserDataSource.clear()
}
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = stringResource(R.string.clear)
)
ClearTextIcon()
}
}
},
@@ -381,7 +371,7 @@ private fun DisplaySearchResults(
channelPicture = item.profilePicture(),
channelTitle = {
Text(
"${item.toBestDisplayName()}",
item.toBestDisplayName(),
fontWeight = FontWeight.Bold
)
},

View File

@@ -32,6 +32,7 @@ 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
import androidx.compose.ui.Modifier
@@ -49,12 +50,16 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.HiddenNote
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteComposeReportState
import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.RenderRelay
import com.vitorpamplona.amethyst.ui.note.RenderReportState
import com.vitorpamplona.amethyst.ui.note.ViewCountReaction
import com.vitorpamplona.amethyst.ui.note.WatchForReports
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
import com.vitorpamplona.amethyst.ui.screen.FeedError
@@ -68,6 +73,9 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun VideoScreen(
@@ -213,7 +221,75 @@ fun SlidingCarousel(
}
) { index ->
feed.value.getOrNull(index)?.let { note ->
RenderVideoOrPictureNote(note, accountViewModel, nav)
LoadedVideoCompose(note, accountViewModel, nav)
}
}
}
@Composable
fun LoadedVideoCompose(
note: Note,
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) {
RenderReportState(
it,
note,
accountViewModel,
nav
)
}
}
@Composable
fun RenderReportState(
state: NoteComposeReportState,
note: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var showReportedNote by remember { mutableStateOf(false) }
Crossfade(targetState = !state.isAcceptable && !showReportedNote) { showHiddenNote ->
if (showHiddenNote) {
Column(remember { Modifier.fillMaxSize(1f) }, verticalArrangement = Arrangement.Center) {
HiddenNote(
state.relevantReports,
accountViewModel,
Modifier,
false,
nav,
onClick = { showReportedNote = true }
)
}
} else {
RenderVideoOrPictureNote(
note,
accountViewModel,
nav
)
}
}
}
@@ -224,7 +300,7 @@ private fun RenderVideoOrPictureNote(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Column(remember { Modifier.fillMaxSize(1f) }) {
Column(remember { Modifier.fillMaxSize(1f) }, verticalArrangement = Arrangement.Center) {
Row(remember { Modifier.weight(1f) }, verticalAlignment = Alignment.CenterVertically) {
val noteEvent = remember { note.event }
if (noteEvent is FileHeaderEvent) {

View File

@@ -12,10 +12,13 @@ val BitcoinOrange = Color(0xFFF7931A)
val RoyalBlue = Color(0xFF4169E1)
val Following = Color(0xFF03DAC5)
val Nip05 = Color(0xFF01BAFF)
val FollowsFollow = Color.Yellow
val NIP05Verified = Color.Blue
val Nip05EmailColor = Color(0xFFb198ec)
val Nip05EmailColorDark = Color(0xFF6e5490)
val Nip05EmailColorLight = Color(0xFFa770f3)
val DarkerGreen = Color.Green.copy(alpha = 0.32f)
val WarningColor = Color(0xFFC62828)

View File

@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.theme
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -72,12 +71,15 @@ val StdPadding = Modifier.padding(10.dp)
val Size6Modifier = Modifier.size(6.dp)
val Size10Modifier = Modifier.size(10.dp)
val Size15Modifier = Modifier.size(15.dp)
val Size16Modifier = Modifier.size(16.dp)
val Size18Modifier = Modifier.size(18.dp)
val Size20Modifier = Modifier.size(20.dp)
val Size22Modifier = Modifier.size(22.dp)
val Size24Modifier = Modifier.size(24.dp)
val Size26Modifier = Modifier.size(26.dp)
val Size30Modifier = Modifier.size(30.dp)
val Size35Modifier = Modifier.size(35.dp)
val Size50Modifier = Modifier.size(50.dp)
val Size55Modifier = Modifier.size(55.dp)
val TinyBorders = Modifier.padding(2.dp)
@@ -92,8 +94,8 @@ val DividerThickness = 0.25.dp
val ReactionRowHeight = Modifier.height(24.dp).padding(start = 10.dp)
val ReactionRowHeightChat = Modifier.height(25.dp)
val UserNameRowHeight = Modifier.height(22.dp).fillMaxWidth()
val UserNameMaxRowHeight = Modifier.heightIn(max = 22.dp).fillMaxWidth()
val UserNameRowHeight = Modifier.fillMaxWidth()
val UserNameMaxRowHeight = Modifier.fillMaxWidth()
val Height4dpModifier = Modifier.height(4.dp)
@@ -112,3 +114,7 @@ val NotificationIconModifierSmaller = Modifier.width(55.dp).padding(end = 4.dp)
val ZapPictureCommentModifier = Modifier.height(35.dp).widthIn(min = 35.dp)
val ChatHeadlineBorders = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)
val VolumeBottomIconSize = Modifier.size(70.dp).padding(10.dp)
val PinBottomIconSize = Modifier.size(70.dp).padding(10.dp)
val NIP05IconSize = Modifier.size(14.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp)

View File

@@ -89,9 +89,6 @@ private val LightReplyItemBackground = LightColorPalette.onSurface.copy(alpha =
private val DarkZapraiserBackground = BitcoinOrange.copy(0.52f).compositeOver(DarkColorPalette.background)
private val LightZapraiserBackground = BitcoinOrange.copy(0.52f).compositeOver(LightColorPalette.background)
private val DarkImageVerifier = Nip05.copy(0.52f).compositeOver(DarkColorPalette.background)
private val LightImageVerifier = Nip05.copy(0.52f).compositeOver(LightColorPalette.background)
private val DarkOverPictureBackground = DarkColorPalette.background.copy(0.62f)
private val LightOverPictureBackground = LightColorPalette.background.copy(0.62f)
@@ -263,6 +260,9 @@ val Colors.veryImportantLink: Color
val Colors.placeholderText: Color
get() = if (isLight) LightPlaceholderText else DarkPlaceholderText
val Colors.nip05: Color
get() = if (isLight) Nip05EmailColorLight else Nip05EmailColorDark
val Colors.placeholderTextColorFilter: ColorFilter
get() = if (isLight) LightPlaceholderTextColorFilter else DarkPlaceholderTextColorFilter
@@ -278,9 +278,6 @@ val Colors.subtleBorder: Color
val Colors.subtleButton: Color
get() = if (isLight) LightSubtleButton else DarkSubtleButton
val Colors.hashVerified: Color
get() = if (isLight) LightImageVerifier else DarkImageVerifier
val Colors.overPictureBackground: Color
get() = if (isLight) LightOverPictureBackground else DarkOverPictureBackground

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="30"
android:viewportHeight="30">
<path
android:pathData="m15.408,21.113 l-5.57,-5.026c-0.679,-0.679 -0.815,-1.766 -0.136,-2.445 0.679,-0.679 1.766,-0.815 2.445,-0.136L15,16.087 19.619,10.924c0.679,-0.679 1.766,-0.815 2.445,-0.136 0.679,0.679 0.815,1.766 0.136,2.445z"
android:fillColor="#6e5490"/>
<path
android:pathData="M15,0.056C6.849,0.056 0.056,6.713 0.056,15c0,8.287 6.657,14.944 14.944,14.944 0.951,0 1.766,-0.815 1.766,-1.766 0,-0.951 -0.815,-1.766 -1.766,-1.766 -6.249,0 -11.411,-5.162 -11.411,-11.411 0,-6.249 5.162,-11.411 11.411,-11.411 6.249,0 11.411,5.162 11.411,11.411 0,2.717 -1.494,5.298 -3.396,6.113 -1.223,0.543 -2.445,0.136 -3.668,-0.951l0,0l-2.309,2.581c2.174,2.038 4.755,2.717 7.336,1.63C27.634,23.015 29.944,19.211 29.944,15 29.808,6.713 23.151,0.056 15,0.056Z"
android:fillColor="#6e5490"/>
</vector>

View File

@@ -0,0 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="30"
android:viewportHeight="30">
<path
android:pathData="M27.8,12.8L27.8,12.8C27.8,5 27.2,0 24.4,0S15,0 15,0S8.4,0 5.6,0C2.8,0 2.2,5 2.2,12.8v0l0,0c0,0 0,0 0,0C2.2,24.9 15,30 15,30S27.8,24.9 27.8,12.8C27.8,12.8 27.8,12.8 27.8,12.8L27.8,12.8z"
android:fillColor="#7F2EFF"/>
<path
android:pathData="M15.1,24l-0.5,-0.2c-0.2,-0.1 -4.5,-2 -6.8,-6.4c-0.3,-0.6 -0.1,-1.4 0.6,-1.7c0.6,-0.3 1.4,-0.1 1.7,0.6c1.4,2.8 3.9,4.4 5,4.9c1.1,-0.6 3.6,-2.2 5,-4.9c0.3,-0.6 1.1,-0.9 1.7,-0.6c0.6,0.3 0.9,1.1 0.6,1.7c-2.2,4.4 -6.6,6.3 -6.8,6.4L15.1,24z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M15.1,24.3l-0.6,-0.3c-0.2,-0.1 -4.6,-2 -6.9,-6.5c-0.2,-0.4 -0.2,-0.8 -0.1,-1.2c0.1,-0.4 0.4,-0.7 0.8,-0.9c0.4,-0.2 0.8,-0.2 1.2,-0.1c0.4,0.1 0.7,0.4 0.9,0.8c1.3,2.6 3.7,4.2 4.8,4.8c1.1,-0.6 3.5,-2.2 4.8,-4.8c0.2,-0.4 0.5,-0.6 0.9,-0.8c0.4,-0.1 0.8,-0.1 1.2,0.1c0.4,0.2 0.6,0.5 0.8,0.9c0.1,0.4 0.1,0.8 -0.1,1.2c-2.3,4.5 -6.7,6.4 -6.9,6.5L15.1,24.3zM8.9,15.8c-0.2,0 -0.3,0 -0.5,0.1C8.2,16 8,16.3 7.9,16.5c-0.1,0.3 -0.1,0.5 0.1,0.8c2.2,4.3 6.5,6.2 6.7,6.3l0.4,0.2l0.4,-0.2c0.2,-0.1 4.5,-2 6.7,-6.3c0.1,-0.2 0.1,-0.5 0.1,-0.8c-0.1,-0.3 -0.3,-0.5 -0.5,-0.6c-0.2,-0.1 -0.5,-0.1 -0.8,-0.1c-0.3,0.1 -0.5,0.3 -0.6,0.5c-1.4,2.8 -4,4.5 -5.1,5.1l-0.1,0.1l-0.1,-0.1c-1,-0.6 -3.6,-2.2 -5.1,-5.1c-0.1,-0.2 -0.3,-0.4 -0.6,-0.5C9.1,15.8 9,15.8 8.9,15.8z"
android:fillColor="#7F2EFF"/>
<path
android:pathData="M15,15c-2.9,0 -5.2,-2.3 -5.2,-5.2s2.3,-5.2 5.2,-5.2s5.2,2.3 5.2,5.2S17.8,15 15,15zM15,7.2c-1.4,0 -2.6,1.2 -2.6,2.6s1.2,2.6 2.6,2.6s2.6,-1.2 2.6,-2.6S16.4,7.2 15,7.2z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M15,15.2c-3,0 -5.4,-2.4 -5.4,-5.4S12,4.4 15,4.4s5.4,2.4 5.4,5.4S18,15.2 15,15.2zM15,4.9c-2.7,0 -4.9,2.2 -4.9,4.9s2.2,4.9 4.9,4.9c2.7,0 4.9,-2.2 4.9,-4.9S17.7,4.9 15,4.9zM15,12.6c-1.6,0 -2.9,-1.3 -2.9,-2.9s1.3,-2.9 2.9,-2.9c1.6,0 2.9,1.3 2.9,2.9S16.5,12.6 15,12.6zM15,7.4c-1.3,0 -2.4,1.1 -2.4,2.4s1.1,2.4 2.4,2.4c1.3,0 2.4,-1.1 2.4,-2.4S16.3,7.4 15,7.4z"
android:fillColor="#7F2EFF"/>
</vector>

View File

@@ -0,0 +1,18 @@
<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="#FF000000"
android:pathData="M810,40L810,261.02C800,257.68 790.83,255.01 782.5,253.01C774.17,251.01 766.64,250 759.92,250C729.39,250 703.44,260.66 682.07,281.99C660.7,303.33 650,329.29 650,359.88C650,390.47 660.66,416.48 681.99,437.89C703.33,459.3 729.29,470 759.88,470C790.47,470 816.48,459.32 837.89,437.93C859.3,416.54 870,390.56 870,360L870,100L960,100L960,40L810,40zM191.25,80L250.08,140L620,140L620,246.99C628.19,236.99 637.42,228 647.66,220C657.89,212 668.67,204.98 680,198.98L680,140C680,123.5 674.13,109.37 662.38,97.62C650.63,85.87 636.5,80 620,80L191.25,80zM80,195.08L80,880L240,720L594.84,720L535.98,660L140,660L140,256.25L80,195.08zM357.97,250L416.84,310L520,310L520,250L357.97,250zM240,370L240,430L310.43,430L251.56,370L240,370zM475.66,370L520,415.2L520,370L475.66,370zM620,473.01L620,517.15L680,578.32L680,521.02C668.67,515.02 657.89,508 647.66,500C637.42,492 628.19,483.01 620,473.01zM240,490L240,550L400,550L400,521.33L369.26,490L240,490z"/>
<path
android:pathData="M61.23,61.69 L873.53,889.89"
android:strokeWidth="160"
android:fillColor="#000000"/>
<path
android:pathData="M64.34,61.69 L876.65,889.89"
android:strokeWidth="60"
android:fillColor="#000000"
android:strokeColor="#000000"/>
</vector>

View File

@@ -0,0 +1,9 @@
<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="#FF000000"
android:pathData="M140,660v-520,520ZM80,880v-740q0,-24.75 17.63,-42.38T140,80h480q24.75,0 42.38,17.63T680,140v59q-17,9 -32.36,21T620,247v-107L140,140v520h480v-187q12.29,15 27.64,27Q663,512 680,521v139q0,24.75 -17.63,42.38T620,720L240,720L80,880ZM240,550h160v-60L240,490v60ZM759.88,470Q714,470 682,437.88q-32,-32.12 -32,-78Q650,314 682.06,282q32.06,-32 77.86,-32 10.08,0 22.58,3t27.5,8v-221h150v60h-90v260q0,45.83 -32.12,77.92 -32.12,32.08 -78,32.08ZM240,430h280v-60L240,370v60ZM240,310h280v-60L240,250v60Z"/>
</vector>

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="30"
android:viewportHeight="30">
<path
android:pathData="m15.408,21.113 l-5.57,-5.026c-0.679,-0.679 -0.815,-1.766 -0.136,-2.445 0.679,-0.679 1.766,-0.815 2.445,-0.136L15,16.087 19.619,10.924c0.679,-0.679 1.766,-0.815 2.445,-0.136 0.679,0.679 0.815,1.766 0.136,2.445z"
android:fillColor="#a770f3"/>
<path
android:pathData="M15,0.056C6.849,0.056 0.056,6.713 0.056,15c0,8.287 6.657,14.944 14.944,14.944 0.951,0 1.766,-0.815 1.766,-1.766 0,-0.951 -0.815,-1.766 -1.766,-1.766 -6.249,0 -11.411,-5.162 -11.411,-11.411 0,-6.249 5.162,-11.411 11.411,-11.411 6.249,0 11.411,5.162 11.411,11.411 0,2.717 -1.494,5.298 -3.396,6.113 -1.223,0.543 -2.445,0.136 -3.668,-0.951l0,0l-2.309,2.581c2.174,2.038 4.755,2.717 7.336,1.63C27.634,23.015 29.944,19.211 29.944,15 29.808,6.713 23.151,0.056 15,0.056Z"
android:fillColor="#a770f3"/>
</vector>

View File

@@ -0,0 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="30"
android:viewportHeight="30">
<path
android:pathData="M15,15m-15,0a15,15 0,1 1,30 0a15,15 0,1 1,-30 0"
android:fillColor="#7F2EFF"/>
<path
android:pathData="M15.3,19.5l-4.1,-3.7c-0.5,-0.5 -0.6,-1.3 -0.1,-1.8s1.3,-0.6 1.8,-0.1l2.1,1.9l3.4,-3.8c0.5,-0.5 1.3,-0.6 1.8,-0.1s0.6,1.3 0.1,1.8L15.3,19.5z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M15.4,19.9L11.1,16c-0.6,-0.6 -0.7,-1.5 -0.1,-2.2c0.3,-0.3 0.7,-0.5 1.1,-0.5c0.4,0 0.8,0.1 1.1,0.4l2,1.8l3.3,-3.7c0.6,-0.6 1.5,-0.7 2.2,-0.1c0.3,0.3 0.5,0.6 0.5,1.1c0,0.4 -0.1,0.8 -0.4,1.1L15.4,19.9zM12.1,13.9L12.1,13.9c-0.3,0 -0.6,0.1 -0.8,0.3c-0.4,0.4 -0.3,1.1 0.1,1.5l3.9,3.5l5,-5.6c0.2,-0.2 0.3,-0.5 0.3,-0.7c0,-0.3 -0.1,-0.5 -0.3,-0.7c-0.4,-0.4 -1.1,-0.3 -1.5,0.1l-3.6,4l-2.4,-2.2C12.6,14 12.4,13.9 12.1,13.9z"
android:fillColor="#7F2EFF"/>
<path
android:pathData="M15,4C9,4 4,8.9 4,15s4.9,11 11,11c0.7,0 1.3,-0.6 1.3,-1.3c0,-0.7 -0.6,-1.3 -1.3,-1.3c-4.6,0 -8.4,-3.8 -8.4,-8.4s3.8,-8.4 8.4,-8.4s8.4,3.8 8.4,8.4c0,2 -1.1,3.9 -2.5,4.5c-0.9,0.4 -1.8,0.1 -2.7,-0.7l0,0l-1.7,1.9c1.6,1.5 3.5,2 5.4,1.2c2.4,-1 4.1,-3.8 4.1,-6.9C25.9,8.9 21,4 15,4z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M15,26.2c-6.2,0 -11.2,-5 -11.2,-11.2S8.8,3.8 15,3.8s11.2,5 11.2,11.2c0,3.1 -1.7,6.1 -4.2,7.1c-1.9,0.8 -4,0.3 -5.6,-1.3l-0.2,-0.2l2,-2.3l0.2,0.2l0,0c0.8,0.8 1.6,1 2.4,0.7c1.3,-0.6 2.3,-2.4 2.3,-4.2c0,-4.5 -3.7,-8.1 -8.1,-8.1c-4.5,-0.1 -8.2,3.6 -8.2,8.1s3.7,8.1 8.1,8.1c0.8,0 1.5,0.7 1.5,1.5C16.5,25.5 15.8,26.2 15,26.2zM15,4.3C9.1,4.3 4.3,9.1 4.3,15S9.1,25.7 15,25.7c0.6,0 1,-0.5 1,-1c0,-0.6 -0.5,-1 -1,-1c-4.8,0 -8.6,-3.9 -8.6,-8.6c0,-4.8 3.9,-8.6 8.6,-8.6s8.6,3.9 8.6,8.6c0,2.1 -1.1,4.1 -2.6,4.7c-0.9,0.4 -1.9,0.2 -2.8,-0.6l-1.4,1.4c1.5,1.3 3.3,1.7 4.9,1c2.3,-1 3.9,-3.7 3.9,-6.6C25.7,9.1 20.9,4.3 15,4.3z"
android:fillColor="#7F2EFF"/>
</vector>

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="30"
android:viewportHeight="30">
<path
android:pathData="M13.4,0.3c0.9,0.5 2.3,0.5 3.1,0c0.9,-0.5 2,-0.3 2.6,0.5s1.9,1.3 2.9,1.2c1,-0.1 2,0.5 2.2,1.5c0.3,1 1.2,1.9 2.2,2.2s1.6,1.3 1.5,2.2c-0.1,1 0.4,2.3 1.2,2.9c0.8,0.6 1,1.8 0.5,2.6c-0.5,0.9 -0.5,2.3 0,3.1c0.5,0.9 0.3,2 -0.5,2.6c-0.8,0.6 -1.3,1.9 -1.2,2.9c0.1,1 -0.5,2 -1.5,2.2c-1,0.3 -1.9,1.2 -2.2,2.2c-0.3,1 -1.3,1.6 -2.2,1.5c-1,-0.1 -2.3,0.4 -2.9,1.2c-0.6,0.8 -1.8,1 -2.6,0.5c-0.9,-0.5 -2.3,-0.5 -3.1,0c-0.9,0.5 -2,0.3 -2.6,-0.5c-0.6,-0.8 -1.9,-1.3 -2.9,-1.2c-1,0.1 -2,-0.5 -2.2,-1.5s-1.2,-1.9 -2.2,-2.2c-1,-0.3 -1.6,-1.3 -1.5,-2.2c0.1,-1 -0.4,-2.3 -1.2,-2.9s-1,-1.8 -0.5,-2.6c0.5,-0.9 0.5,-2.3 0,-3.1c-0.5,-0.9 -0.3,-2 0.5,-2.6S2.1,8.9 2,7.9s0.5,-2 1.5,-2.2s1.9,-1.2 2.2,-2.2S6.9,1.8 7.9,2s2.3,-0.4 2.9,-1.2S12.6,-0.2 13.4,0.3z"
android:fillColor="#7F2EFF"/>
<path
android:pathData="M0.3,24.5c-0.4,0.4 -0.3,0.8 0.3,0.9l2.5,0.4c0.5,0.1 1.1,0.6 1.1,1.1l0.4,2.5c0.1,0.5 0.5,0.7 0.9,0.3L29.7,5.5c0.4,-0.4 0.3,-0.8 -0.3,-0.9l-2.5,-0.4c-0.5,-0.1 -1.1,-0.6 -1.1,-1.1l-0.4,-2.5c-0.1,-0.5 -0.5,-0.7 -0.9,-0.3L0.3,24.5z"
android:fillColor="#BA8AF9"/>
</vector>

View File

@@ -23,6 +23,7 @@
<string name="copy_note_id">Kopírovat ID poznámky</string>
<string name="broadcast">Vysílání</string>
<string name="request_deletion">Požadovat smazání</string>
<string name="block_report">Blockovat / Nahlásit</string>
<string name="block_hide_user"><![CDATA[Blokovat a skrýt uživatele]]></string>
<string name="report_spam_scam">Nahlásit spam / podvod</string>
<string name="report_impersonation">Nahlásit zneužívání identity</string>
@@ -43,11 +44,11 @@
<string name="new_amount_in_sats">Nová částka v sats</string>
<string name="add">Přidat</string>
<string name="replying_to">"odpovídá na "</string>
<string name="and">"a "</string>
<string name="and">" a "</string>
<string name="in_channel">"v kanálu "</string>
<string name="profile_banner">Banner profilu</string>
<string name="following">"Sleduje"</string>
<string name="followers">"Sledující"</string>
<string name="following">" Sleduje"</string>
<string name="followers">" Sledující"</string>
<string name="profile">Profil</string>
<string name="security_filters">Bezpečnostní filtry</string>
<string name="log_out">Odhlásit</string>
@@ -164,6 +165,7 @@
<string name="translations_show_in_lang_first">Zobrazit nejprve v %1$s</string>
<string name="translations_always_translate_to_lang">Vždy překládat do %1$s</string>
<string name="translations_never_translate_from_lang">Nikdy nepřekládat z %1$s</string>
<string name="nip_05">Nostr Adresa</string>
<string name="never">nikdy</string>
<string name="now">nyní</string>
<string name="h">h</string>
@@ -447,4 +449,38 @@
<string name="followed_tags">Sledované značky</string>
<string name="relay_setup">Rele</string>
<string name="discover_live">Živě</string>
<string name="discover_community">Komunita</string>
<string name="discover_chat">Chaty</string>
<string name="community_approved_posts">Schválené příspěvky</string>
<string name="groups_no_descriptor">Tato skupina nemá popis ani pravidla. Promluvte si s majitelem, aby je přidal/a.</string>
<string name="community_no_descriptor">Tato komunita nemá popis. Promluvte si s majitelem, aby ho přidal/a.</string>
<string name="add_sensitive_content_label">Citlivý obsah</string>
<string name="add_sensitive_content_description">Před zobrazením tohoto obsahu přidá upozornění na citlivý obsah.</string>
<string name="settings">Nastavení</string>
<string name="always">Vždy</string>
<string name="wifi_only">Pouze Wi-Fi</string>
<string name="system">Systém</string>
<string name="light">Světlý</string>
<string name="dark">Tmavý</string>
<string name="application_preferences">Předvolby aplikace</string>
<string name="language">Jazyk</string>
<string name="theme">Motiv</string>
<string name="automatically_load_images_gifs">Automaticky načítat obrázky/gif</string>
<string name="automatically_play_videos">Automaticky přehrávat videa</string>
<string name="automatically_show_url_preview">Automaticky zobrazit náhled URL</string>
<string name="load_image">Načíst obrázek</string>
<string name="spamming_users">Spamovací uživatelé</string>
<string name="muted_button">Ztlumené. Klikněte pro odztlumení</string>
<string name="mute_button">Zvuk zapnutý. Klikněte pro ztlumení</string>
<string name="search_button">Hledat lokální a vzdálené záznamy</string>
<string name="nip05_verified">Adresa Nostr byla ověřena</string>
<string name="nip05_failed">Ověření adresy Nostr se nezdařilo</string>
<string name="nip05_checking">Kontrola adresy Nostr</string>
</resources>

View File

@@ -24,6 +24,7 @@
<string name="broadcast">Senden</string>
<string name="request_deletion">Löschung beantragen</string>
<string name="block_hide_user"><![CDATA[Benutzer blockieren und ausblenden]]></string>
<string name="block_report">Blockieren / Melden</string>
<string name="report_spam_scam">Spam / Betrug melden</string>
<string name="report_impersonation">Vortäuschung melden</string>
<string name="report_explicit_content">Expliziten Inhalt melden</string>
@@ -168,6 +169,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="translations_show_in_lang_first">Zuerst in %1$s anzeigen</string>
<string name="translations_always_translate_to_lang">Immer ins %1$s übersetzen</string>
<string name="translations_never_translate_from_lang">Niemals aus dem %1$s übersetzen</string>
<string name="nip_05">Nostr-Adresse</string>
<string name="never">nie</string>
<string name="now">jetzt</string>
<string name="h">h</string>
@@ -456,4 +458,38 @@ anz der Bedingungen ist erforderlich</string>
<string name="followed_tags">Gefolgte Tags</string>
<string name="relay_setup">Relais</string>
<string name="discover_live">Live</string>
<string name="discover_community">Community</string>
<string name="discover_chat">Chats</string>
<string name="community_approved_posts">Genehmigte Beiträge</string>
<string name="groups_no_descriptor">Diese Gruppe hat keine Beschreibung oder Regeln. Sprechen Sie mit dem Eigentümer, um eine hinzuzufügen.</string>
<string name="community_no_descriptor">Diese Community hat keine Beschreibung. Sprechen Sie mit dem Eigentümer, um eine hinzuzufügen.</string>
<string name="add_sensitive_content_label">Sensibler Inhalt</string>
<string name="add_sensitive_content_description">Fügt eine Warnung für sensiblen Inhalt hinzu, bevor dieser Inhalt angezeigt wird.</string>
<string name="settings">Einstellungen</string>
<string name="always">Immer</string>
<string name="wifi_only">Nur WLAN</string>
<string name="system">System</string>
<string name="light">Hell</string>
<string name="dark">Dunkel</string>
<string name="application_preferences">Anwendungseinstellungen</string>
<string name="language">Sprache</string>
<string name="theme">Design</string>
<string name="automatically_load_images_gifs">Bilder/GIFs automatisch laden</string>
<string name="automatically_play_videos">Videos automatisch abspielen</string>
<string name="automatically_show_url_preview">URL-Vorschau automatisch anzeigen</string>
<string name="load_image">Bild laden</string>
<string name="spamming_users">Spammer</string>
<string name="muted_button">Ton aus. Klicken, um Ton einzuschalten</string>
<string name="mute_button">Ton an. Klicken, um Ton auszuschalten</string>
<string name="search_button">Lokale und entfernte Einträge durchsuchen</string>
<string name="nip05_verified">Nostr-Adresse verifiziert</string>
<string name="nip05_failed">Nostr-Adresse konnte nicht verifiziert werden</string>
<string name="nip05_checking">Nostr-Adresse wird überprüft</string>
</resources>

View File

@@ -23,6 +23,7 @@
<string name="broadcast">Sänd ut</string>
<string name="request_deletion">Begär radering</string>
<string name="block_hide_user"><![CDATA[Blockera och göm användare]]></string>
<string name="block_report">Blockera / Rapportera</string>
<string name="report_spam_scam">Rapportera Spam / Scam</string>
<string name="report_impersonation">Rapportera identitetsstöld</string>
<string name="report_explicit_content">Rapportera explicit innehåll</string>
@@ -163,6 +164,7 @@
<string name="translations_show_in_lang_first">Visa i %1$s first</string>
<string name="translations_always_translate_to_lang">Översätt alltid till %1$s</string>
<string name="translations_never_translate_from_lang">Översätt aldrig från %1$s</string>
<string name="nip_05">Nostr-adress</string>
<string name="never">aldrig</string>
<string name="now">nu</string>
<string name="h">h</string>
@@ -444,6 +446,40 @@
<string name="followed_tags">Följda taggar</string>
<string name="relay_setup">Reläer</string>
<string name="discover_live">Live</string>
<string name="discover_community">Gemenskap</string>
<string name="discover_chat">Chattar</string>
<string name="community_approved_posts">Godkända Inlägg</string>
<string name="groups_no_descriptor">Denna grupp har ingen beskrivning eller regler. Prata med ägaren för att lägga till en.</string>
<string name="community_no_descriptor">Denna gemenskap har ingen beskrivning. Prata med ägaren för att lägga till en.</string>
<string name="add_sensitive_content_label">Känsligt innehåll</string>
<string name="add_sensitive_content_description">Lägger till varning för känsligt innehåll innan detta innehåll visas.</string>
<string name="settings">Inställningar</string>
<string name="always">Alltid</string>
<string name="wifi_only">Endast Wi-Fi</string>
<string name="system">System</string>
<string name="light">Ljus</string>
<string name="dark">Mörk</string>
<string name="application_preferences">Applikationsinställningar</string>
<string name="language">Språk</string>
<string name="theme">Tema</string>
<string name="automatically_load_images_gifs">Ladda automatiskt bilder/gif</string>
<string name="automatically_play_videos">Spela automatiskt videor</string>
<string name="automatically_show_url_preview">Visa automatiskt förhandsgranskning av URL</string>
<string name="load_image">Ladda bild</string>
<string name="spamming_users">Spammare</string>
<string name="muted_button">Ljud avstängt. Klicka för att ta bort ljudlöst</string>
<string name="mute_button">Ljud på. Klicka för att stänga av ljudet</string>
<string name="search_button">Sök lokala och externa poster</string>
<string name="nip05_verified">Nostr-adress verifierad</string>
<string name="nip05_failed">Verifikation av Nostr-adress misslyckades</string>
<string name="nip05_checking">Kontrollerar Nostr-adress</string>
</resources>

View File

@@ -299,8 +299,8 @@
<string name="poll_author_no_vote">Poll authors can\'t vote in their own polls.</string>
<string name="poll_hashtag" translatable="false">#zappoll</string>
<string name="hash_verification_passed">Image is the same since the post</string>
<string name="hash_verification_failed">Image has changed. The author might not have seen the change</string>
<string name="hash_verification_passed">This content is the same since the post</string>
<string name="hash_verification_failed">This content has changed. The author might not have seen or approved the change</string>
<string name="content_description_add_image">Add Image</string>
<string name="content_description_add_video">Add Video</string>
@@ -494,4 +494,14 @@
<string name="automatically_play_videos">Automatically play videos</string>
<string name="automatically_show_url_preview">Automatically show url preview</string>
<string name="load_image">Load Image</string>
<string name="spamming_users">Spammers</string>
<string name="muted_button">Muted. Click to unmute</string>
<string name="mute_button">Sound on. Click to mute</string>
<string name="search_button">Search local and remote records</string>
<string name="nip05_verified">Nostr address was verified</string>
<string name="nip05_failed">Nostr address failed verification</string>
<string name="nip05_checking">Checking Nostr address</string>
</resources>

View File

@@ -2,6 +2,7 @@
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="ar"/>
<locale android:name="cs"/>
<locale android:name="de"/>
<locale android:name="eo"/>
<locale android:name="es"/>
<locale android:name="fa"/>

View File

@@ -8,7 +8,7 @@ buildscript {
accompanist_version = '0.30.0'
coil_version = '2.4.0'
vico_version = '1.7.1'
exoplayer_version = '2.19.0'
exoplayer_version = '1.1.0'
}
dependencies {
classpath 'com.google.gms:google-services:4.3.15'

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#7F2EFF;}
</style>
<g>
<g>
<path class="st0" d="M25.7,1.3H5.6c-0.6,0-1,0.5-1,1s0.5,1,1,1l21.1,2.3V2.1C26.6,1.5,26.3,1.3,25.7,1.3z"/>
</g>
<g>
<path class="st1" d="M24.7,0H5.3c-1.1,0-2,0.9-2,2v26c0,1.1,0.9,2,2,2h19.4c1.1,0,2-0.9,2-2V4.4c0-0.6-0.5-1-1-1H5.6
c-0.5,0-1-0.4-1.1-0.9c0-0.7,0.5-1.2,1.1-1.2h20.1c0.5,0,0.9,0.4,1,0.8l0,0V2C26.7,0.9,25.8,0,24.7,0z"/>
</g>
<g>
<g>
<path class="st0" d="M14.9,21.8l-5.5-5c-0.5-0.5-0.6-1.3-0.1-1.8s1.3-0.6,1.8-0.1l3.6,3.2l5.4-6.1c0.5-0.5,1.3-0.6,1.8-0.1
s0.6,1.3,0.1,1.8L14.9,21.8z"/>
<path class="st1" d="M14.9,22.1L9.2,17c-0.3-0.3-0.5-0.7-0.5-1.1s0.1-0.8,0.4-1.1c0.3-0.3,0.7-0.5,1.1-0.5c0.4,0,0.8,0.1,1.1,0.4
l3.4,3.1l5.3-5.9c0.3-0.3,0.7-0.5,1.1-0.5s0.8,0.1,1.1,0.4c0.3,0.3,0.5,0.7,0.5,1.1c0,0.4-0.1,0.8-0.4,1.1L14.9,22.1z M10.2,14.8
L10.2,14.8c-0.3,0-0.6,0.1-0.8,0.3s-0.3,0.5-0.3,0.8s0.1,0.5,0.3,0.7l5.3,4.8l7-7.9c0.2-0.2,0.3-0.5,0.3-0.8s-0.1-0.5-0.3-0.7
c-0.2-0.2-0.5-0.3-0.8-0.3s-0.5,0.1-0.7,0.3l-5.6,6.3l-3.8-3.4C10.7,14.9,10.5,14.8,10.2,14.8z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Some files were not shown because too many files have changed in this diff Show More