mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9813c2c8f | ||
|
|
0c00e6512f | ||
|
|
cf31bfe5ea | ||
|
|
5cdceb5194 | ||
|
|
2c82e6c447 | ||
|
|
f56f3b6105 | ||
|
|
9420ddc5c7 | ||
|
|
ade1d83817 | ||
|
|
9a517380a0 | ||
|
|
6fbb59ddac | ||
|
|
fe4a4b6fad | ||
|
|
8dd5705f02 | ||
|
|
6723225885 | ||
|
|
fb19454259 | ||
|
|
c6f63a13d3 | ||
|
|
77057fcac5 | ||
|
|
676d5e9b88 | ||
|
|
d6dda0053a | ||
|
|
d33e62367e | ||
|
|
47d7a53c1c | ||
|
|
6fcf472638 | ||
|
|
e2f5a1a0e4 | ||
|
|
8ea25e735d | ||
|
|
a9d05070ed | ||
|
|
d78ec6b02a | ||
|
|
8971b4e190 | ||
|
|
dea0adaea4 | ||
|
|
5de4fbcbe7 | ||
|
|
2330e086ea | ||
|
|
11d51b3854 | ||
|
|
008469adbc | ||
|
|
bbe26c7a30 | ||
|
|
c2b9bb413d | ||
|
|
5c1c4f8db2 | ||
|
|
b46bb7ecc5 | ||
|
|
2fed65db8d | ||
|
|
66cc4b5526 | ||
|
|
91488f0350 | ||
|
|
f2429e011e | ||
|
|
1374f36b1f | ||
|
|
d185e65b3f | ||
|
|
64bf17b1a0 | ||
|
|
93b50468fd | ||
|
|
65ab756c2b | ||
|
|
e298285145 | ||
|
|
d730eeb9a1 | ||
|
|
0d63441611 | ||
|
|
f0ecf382e0 | ||
|
|
d0dee84031 | ||
|
|
ad59b691fd | ||
|
|
a256e34878 | ||
|
|
6ad00c112a | ||
|
|
0010a19c24 | ||
|
|
d78c7a91cb | ||
|
|
4a2dced02f | ||
|
|
2d1a63be57 | ||
|
|
9e207d0f24 | ||
|
|
efad94ddfd | ||
|
|
d49fde755e | ||
|
|
0224af18c0 | ||
|
|
53a4dfb702 | ||
|
|
4e4e798133 | ||
|
|
e8bd4a12ac | ||
|
|
dffed6e78f | ||
|
|
46a4f360f7 | ||
|
|
64a5071345 | ||
|
|
1fba93b4f0 | ||
|
|
caa67b03a8 | ||
|
|
5f1b2f9eb8 | ||
|
|
2968cb12aa | ||
|
|
8261b38de9 | ||
|
|
2c4489a0d0 | ||
|
|
52dcb73fd7 |
@@ -41,7 +41,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Created_at Limits (NIP-22)
|
||||
- [x] Event Deletion (NIP-09)
|
||||
- [x] Identity Verification (NIP-05)
|
||||
- [x] Nostr Address (NIP-05)
|
||||
- [x] Long-form Content (NIP-23)
|
||||
- [x] Parameterized Replaceable Events (NIP-33)
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
|
||||
@@ -13,14 +13,14 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 251
|
||||
versionName "0.68.3"
|
||||
versionCode 260
|
||||
versionName "0.70.3"
|
||||
|
||||
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'
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
124
app/src/main/java/com/vitorpamplona/amethyst/PlaybackService.kt
Normal file
124
app/src/main/java/com/vitorpamplona/amethyst/PlaybackService.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,6 @@ object ServiceManager {
|
||||
.build()
|
||||
}
|
||||
|
||||
// Initializes video cache.
|
||||
VideoCache.init(context.applicationContext)
|
||||
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
|
||||
|
||||
@@ -79,6 +76,7 @@ object ServiceManager {
|
||||
NostrAccountDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
NostrDiscoveryDataSource.start()
|
||||
NostrVideoDataSource.start()
|
||||
|
||||
// More Info Data Sources
|
||||
NostrSingleEventDataSource.start()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,8 +1547,11 @@ class Account(
|
||||
Log.d("Init", "Account")
|
||||
backupContactList?.let {
|
||||
println("Loading saved contacts ${it.toJson()}")
|
||||
|
||||
if (userProfile().latestContactList == null) {
|
||||
LocalCache.consume(it)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
LocalCache.consume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -238,9 +248,6 @@ object LocalCache {
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Prepares user's profile view.
|
||||
author.addNote(note)
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach {
|
||||
it.addReply(note)
|
||||
@@ -279,8 +286,6 @@ object LocalCache {
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
author.addNote(note)
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
@@ -310,9 +315,6 @@ object LocalCache {
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Prepares user's profile view.
|
||||
author.addNote(note)
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach {
|
||||
it.addReply(note)
|
||||
@@ -518,7 +520,7 @@ object LocalCache {
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
author.updateAcceptedBadges(note)
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,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())
|
||||
@@ -554,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())
|
||||
@@ -588,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)
|
||||
@@ -634,8 +646,6 @@ object LocalCache {
|
||||
event.deleteEvents().mapNotNull { notes[it] }.forEach { deleteNote ->
|
||||
// must be the same author
|
||||
if (deleteNote.author?.pubkeyHex == event.pubKey) {
|
||||
deleteNote.author?.removeNote(deleteNote)
|
||||
|
||||
// reverts the add
|
||||
val mentions = deleteNote.event?.tags()?.filter { it.firstOrNull() == "p" }
|
||||
?.mapNotNull { it.getOrNull(1) }?.mapNotNull { checkGetOrCreateUser(it) }
|
||||
@@ -697,9 +707,6 @@ object LocalCache {
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
|
||||
// Prepares user's profile view.
|
||||
author.addNote(note)
|
||||
|
||||
// Counts the replies
|
||||
repliesTo.forEach {
|
||||
it.addBoost(note)
|
||||
@@ -722,9 +729,6 @@ object LocalCache {
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
|
||||
// Prepares user's profile view.
|
||||
author.addNote(note)
|
||||
|
||||
// Counts the replies
|
||||
repliesTo.forEach {
|
||||
it.addBoost(note)
|
||||
@@ -750,9 +754,6 @@ object LocalCache {
|
||||
|
||||
note.loadEvent(event, author, eventsApproved)
|
||||
|
||||
// Prepares user's profile view.
|
||||
author.addNote(note)
|
||||
|
||||
// Counts the replies
|
||||
repliesTo.forEach {
|
||||
it.addBoost(note)
|
||||
@@ -1072,9 +1073,6 @@ object LocalCache {
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
// Adds to user profile
|
||||
author.addNote(note)
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
@@ -1245,24 +1243,26 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}. ${it.value.notes.size} kept")
|
||||
if (toBeRemoved.size > 100 || it.value.notes.size > 100) {
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}. ${it.value.notes.size} kept")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneHiddenMessages(account: Account) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val toBeRemoved = account.hiddenUsers.map {
|
||||
(users[it]?.notes ?: emptySet())
|
||||
val toBeRemoved = account.hiddenUsers.map { userHex ->
|
||||
(
|
||||
notes.values.filter {
|
||||
it.event?.pubKey() == userHex
|
||||
} + addressables.values.filter {
|
||||
it.event?.pubKey() == userHex
|
||||
}
|
||||
).toSet()
|
||||
}.flatten()
|
||||
|
||||
account.hiddenUsers.forEach {
|
||||
users[it]?.clearNotes()
|
||||
}
|
||||
|
||||
toBeRemoved.forEach {
|
||||
it.author?.removeNote(it)
|
||||
|
||||
// Counts the replies
|
||||
it.replyTo?.forEach { masterNote ->
|
||||
masterNote.removeReply(it)
|
||||
@@ -1355,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)
|
||||
|
||||
@@ -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? {
|
||||
@@ -212,26 +216,58 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun innerAddZap(zapRequest: Note, zap: Note?): Boolean {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
return true
|
||||
} else if (zaps[zapRequest] == null) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
checkNotInMainThread()
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
val inserted = innerAddZap(zapRequest, zap)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
} else if (zaps[zapRequest] == null) {
|
||||
val inserted = innerAddZap(zapRequest, zap)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun innerAddZapPayment(zapPaymentRequest: Note, zapPayment: Note?): Boolean {
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
return true
|
||||
} else if (zapPayments[zapPaymentRequest] == null) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
|
||||
checkNotInMainThread()
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapPaymentRequest in zapPayments.keys && zapPayments[zapPaymentRequest] == null) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
} else if (zapPayments[zapPaymentRequest] == null) {
|
||||
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,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) {
|
||||
|
||||
@@ -31,10 +31,6 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
var latestContactList: ContactListEvent? = null
|
||||
var latestBookmarkList: BookmarkListEvent? = null
|
||||
var latestAcceptedBadges: AddressableNote? = null
|
||||
|
||||
var notes = setOf<Note>()
|
||||
private set
|
||||
|
||||
var reports = mapOf<User, Set<Note>>()
|
||||
private set
|
||||
@@ -55,6 +51,8 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
|
||||
|
||||
fun toNostrUri() = "nostr:${pubkeyNpub()}"
|
||||
|
||||
override fun toString(): String = pubkeyHex
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
@@ -106,21 +104,6 @@ class User(val pubkeyHex: String) {
|
||||
liveSet?.relays?.invalidateData()
|
||||
}
|
||||
|
||||
fun addNote(note: Note) {
|
||||
if (note !in notes) {
|
||||
notes = notes + note
|
||||
// No need for Listener yet
|
||||
}
|
||||
}
|
||||
|
||||
fun removeNote(note: Note) {
|
||||
notes = notes - note
|
||||
}
|
||||
|
||||
fun clearNotes() {
|
||||
notes = setOf<Note>()
|
||||
}
|
||||
|
||||
fun addReport(note: Note) {
|
||||
val author = note.author ?: return
|
||||
|
||||
@@ -144,13 +127,6 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAcceptedBadges(note: AddressableNote) {
|
||||
if (latestAcceptedBadges?.idHex != note.idHex) {
|
||||
latestAcceptedBadges = note
|
||||
liveSet?.badges?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
@@ -301,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
|
||||
}
|
||||
@@ -351,7 +331,6 @@ class UserLiveSet(u: User) {
|
||||
val relayInfo: UserLiveData = UserLiveData(u)
|
||||
val metadata: UserLiveData = UserLiveData(u)
|
||||
val zaps: UserLiveData = UserLiveData(u)
|
||||
val badges: UserLiveData = UserLiveData(u)
|
||||
val bookmarks: UserLiveData = UserLiveData(u)
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
@@ -363,7 +342,6 @@ class UserLiveSet(u: User) {
|
||||
relayInfo.hasObservers() ||
|
||||
metadata.hasObservers() ||
|
||||
zaps.hasObservers() ||
|
||||
badges.hasObservers() ||
|
||||
bookmarks.hasObservers()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -17,13 +17,12 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
val dataSource = "Discovery"
|
||||
|
||||
fun createLiveStreamFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 6)
|
||||
it.substring(0, 8)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
@@ -32,7 +31,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
authors = followKeys,
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -41,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(
|
||||
@@ -50,7 +49,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
authors = followKeys,
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -59,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(
|
||||
@@ -68,7 +67,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
authors = followKeys,
|
||||
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -88,7 +87,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -108,7 +107,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -128,12 +127,14 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val discoveryFeedChannel = requestNewChannel()
|
||||
val discoveryFeedChannel = requestNewChannel() { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultDiscoveryFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
discoveryFeedChannel.typedFilters = listOfNotNull(
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
@@ -10,6 +11,8 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
fun createContextualFilter(): TypedFilter? {
|
||||
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
|
||||
|
||||
@@ -22,7 +25,8 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
limit = 200
|
||||
limit = 200,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -41,12 +45,15 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val videoFeedChannel = requestNewChannel()
|
||||
val videoFeedChannel = requestNewChannel() { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultStoriesFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
|
||||
@@ -11,7 +11,12 @@ object ConnectivityStatus {
|
||||
val isOnWifi: MutableState<Boolean> = onWifi
|
||||
|
||||
fun updateConnectivityStatus(isOnMobileData: Boolean, isOnWifi: Boolean) {
|
||||
onMobileData.value = isOnMobileData
|
||||
onWifi.value = isOnWifi
|
||||
if (onMobileData.value != isOnMobileData) {
|
||||
onMobileData.value = isOnMobileData
|
||||
}
|
||||
|
||||
if (onWifi.value != isOnWifi) {
|
||||
onWifi.value = isOnWifi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,6 @@ class BadgeProfilesEvent(
|
||||
|
||||
companion object {
|
||||
const val kind = 30008
|
||||
const val standardDTAg = "profile_badges"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ interface LnZapEventInterface : EventInterface {
|
||||
|
||||
fun zappedRequestAuthor(): String?
|
||||
|
||||
fun taggedAddresses(): List<ATag>
|
||||
|
||||
fun amount(): BigDecimal?
|
||||
|
||||
fun containedPost(): Event?
|
||||
|
||||
@@ -147,8 +147,8 @@ class MetadataEvent(
|
||||
UserMetadata::class.java
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.w("MT", "Content Parse Error ${e.localizedMessage} $content")
|
||||
// e.printStackTrace()
|
||||
Log.w("MT", "Content Parse Error: ${e.localizedMessage} $content")
|
||||
null
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -139,14 +205,18 @@ class MainActivity : AppCompatActivity() {
|
||||
networkCapabilities: NetworkCapabilities
|
||||
) {
|
||||
super.onCapabilitiesChanged(network, networkCapabilities)
|
||||
val hasMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
val hasWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasMobileData $hasMobileData")
|
||||
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasWifi $hasWifi")
|
||||
ConnectivityStatus.updateConnectivityStatus(
|
||||
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR),
|
||||
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val hasMobileData =
|
||||
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
val hasWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasMobileData $hasMobileData")
|
||||
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasWifi $hasWifi")
|
||||
ConnectivityStatus.updateConnectivityStatus(
|
||||
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR),
|
||||
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// lost network connection
|
||||
@@ -192,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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font17SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
|
||||
@@ -285,7 +286,9 @@ private fun ZoomableContentView(
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
state.imagesForPager[word]?.let {
|
||||
ZoomableContentView(it, state.imageList, accountViewModel)
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(it, state.imageList, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -10,7 +9,6 @@ import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.LocalUriHandler
|
||||
@@ -22,8 +20,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
|
||||
@Composable
|
||||
fun UrlPreviewCard(
|
||||
@@ -46,14 +43,10 @@ fun UrlPreviewCard(
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable { runCatching { uri.openUri(url) } }
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.subtleBorder,
|
||||
QuoteBorder
|
||||
)
|
||||
modifier = MaterialTheme.colors.innerPostModifier
|
||||
.clickable {
|
||||
runCatching { uri.openUri(url) }
|
||||
}
|
||||
) {
|
||||
Column {
|
||||
AsyncImage(
|
||||
|
||||
@@ -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,7 +28,7 @@ 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
|
||||
@@ -47,34 +45,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 +106,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 +188,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 +470,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 +507,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 +623,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 +639,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, MaterialTheme.colors.onBackground)
|
||||
} else {
|
||||
LyricsOffIcon(Size22Modifier, MaterialTheme.colors.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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() &&
|
||||
(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -5,21 +5,37 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.AppRecommendationEvent
|
||||
|
||||
class UserProfileAppRecommendationsFeedFilter(val user: User) : FeedFilter<Note>() {
|
||||
class UserProfileAppRecommendationsFeedFilter(val user: User) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return user.pubkeyHex
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val recommendations = LocalCache.addressables.values.filter {
|
||||
(it.event as? AppRecommendationEvent)?.pubKey == user.pubkeyHex
|
||||
}.mapNotNull {
|
||||
(it.event as? AppRecommendationEvent)?.recommendations()
|
||||
}.flatten()
|
||||
.map {
|
||||
return sort(innerApplyFilter(LocalCache.addressables.values))
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val recommendations = collection.asSequence()
|
||||
.filter { it.event is AppRecommendationEvent }
|
||||
.mapNotNull {
|
||||
val noteEvent = it.event as? AppRecommendationEvent
|
||||
if (noteEvent != null && noteEvent.pubKey == user.pubkeyHex) {
|
||||
noteEvent.recommendations()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.flatten().map {
|
||||
LocalCache.getOrCreateAddressableNote(it)
|
||||
}.toSet().toList()
|
||||
}.toSet()
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,45 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
class UserProfileConversationsFeedFilter(val user: User, val account: Account) : FeedFilter<Note>() {
|
||||
class UserProfileConversationsFeedFilter(val user: User, val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + user.pubkeyHex
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return user.notes
|
||||
.filter { account.isAcceptable(it) == true && !it.isNewThread() }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed() ?: emptyList()
|
||||
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
return collection
|
||||
.filter {
|
||||
it.author == user &&
|
||||
(
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is LiveActivitiesChatMessageEvent
|
||||
) &&
|
||||
!it.isNewThread() &&
|
||||
account.isAcceptable(it) == true
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
|
||||
override fun limit() = 200
|
||||
}
|
||||
|
||||
@@ -12,4 +12,6 @@ class UserProfileFollowersFeedFilter(val user: User, val account: Account) : Fee
|
||||
override fun feed(): List<User> {
|
||||
return LocalCache.users.values.filter { it.isFollowing(user) && !account.isHidden(it) }
|
||||
}
|
||||
|
||||
override fun limit() = 400
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
class UserProfileFollowsFeedFilter(val user: User, val account: Account) : FeedFilter<User>() {
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile()?.pubkeyHex + "-" + user.pubkeyHex
|
||||
return account.userProfile().pubkeyHex + "-" + user.pubkeyHex
|
||||
}
|
||||
|
||||
val cache: MutableMap<ContactListEvent, List<User>> = mutableMapOf()
|
||||
|
||||
@@ -4,32 +4,53 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.AppRecommendationEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
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.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
class UserProfileNewThreadFeedFilter(val user: User, val account: Account) : FeedFilter<Note>() {
|
||||
class UserProfileNewThreadFeedFilter(val user: User, val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + user.pubkeyHex
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val longFormNotes = LocalCache.addressables.values
|
||||
val notes = innerApplyFilter(LocalCache.notes.values)
|
||||
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
|
||||
|
||||
return sort(notes + longFormNotes)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
return collection
|
||||
.filter {
|
||||
it.author == user &&
|
||||
(
|
||||
it.event !is PeopleListEvent &&
|
||||
it.event !is BookmarkListEvent &&
|
||||
it.event !is AppRecommendationEvent &&
|
||||
it.event !is EmojiPackSelectionEvent
|
||||
)
|
||||
}
|
||||
|
||||
return user.notes
|
||||
.plus(longFormNotes)
|
||||
.filter { account.isAcceptable(it) == true && it.isNewThread() }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed() ?: emptyList()
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is ClassifiedsEvent ||
|
||||
it.event is RepostEvent ||
|
||||
it.event is GenericRepostEvent ||
|
||||
it.event is LongTextNoteEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is HighlightEvent ||
|
||||
it.event is AudioTrackEvent
|
||||
) &&
|
||||
it.isNewThread() &&
|
||||
account.isAcceptable(it) == true
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
|
||||
override fun limit() = 200
|
||||
}
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
|
||||
class UserProfileReportsFeedFilter(val user: User) : FeedFilter<Note>() {
|
||||
class UserProfileReportsFeedFilter(val user: User) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return user.pubkeyHex ?: ""
|
||||
return user.pubkeyHex
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val reportNotes = LocalCache.notes.values.filter { (it.event as? ReportEvent)?.isTaggedUser(user.pubkeyHex) == true }
|
||||
|
||||
return reportNotes
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
return sort(innerApplyFilter(user.reports.values.flatten()))
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
return collection.filter { it.event is ReportEvent && it.event?.isTaggedUser(user.pubkeyHex) == true }.toSet()
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
|
||||
override fun limit() = 400
|
||||
}
|
||||
|
||||
@@ -13,4 +13,6 @@ class UserProfileZapsFeedFilter(val user: User) : FeedFilter<ZapReqResponse>() {
|
||||
override fun feed(): List<ZapReqResponse> {
|
||||
return UserZaps.forProfileFeed(user.zaps)
|
||||
}
|
||||
|
||||
override fun limit() = 400
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ChatroomCompose(
|
||||
fun ChatroomHeaderCompose(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
@@ -305,13 +305,16 @@ private fun WatchNotificationChanges(
|
||||
@Composable
|
||||
fun LoadUser(baseUserHex: String, content: @Composable (User?) -> Unit) {
|
||||
var user by remember(baseUserHex) {
|
||||
mutableStateOf<User?>(LocalCache.getUserIfExists(baseUserHex))
|
||||
mutableStateOf(LocalCache.getUserIfExists(baseUserHex))
|
||||
}
|
||||
|
||||
if (user == null) {
|
||||
LaunchedEffect(key1 = baseUserHex) {
|
||||
launch(Dispatchers.IO) {
|
||||
user = LocalCache.checkGetOrCreateUser(baseUserHex)
|
||||
val newUser = LocalCache.checkGetOrCreateUser(baseUserHex)
|
||||
if (user != newUser) {
|
||||
user = newUser
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
379
app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt
Normal file
379
app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt
Normal 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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
"NIP-05 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 Nip05",
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.ChatroomCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.ChatroomHeaderCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
@@ -113,7 +113,7 @@ private fun FeedLoaded(
|
||||
) { _, item ->
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
ChatroomCompose(
|
||||
ChatroomHeaderCompose(
|
||||
item,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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())) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
@@ -80,6 +79,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
|
||||
@@ -123,6 +123,10 @@ import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldLeadingIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
|
||||
@@ -350,15 +354,13 @@ fun EditFieldRow(
|
||||
accountViewModel: AccountViewModel,
|
||||
onSendNewMessage: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp)
|
||||
.fillMaxWidth(),
|
||||
modifier = EditFieldModifier,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
MyTextField(
|
||||
value = channelScreenModel.message,
|
||||
onValueChange = {
|
||||
@@ -367,7 +369,7 @@ fun EditFieldRow(
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
shape = RoundedCornerShape(25.dp),
|
||||
shape = EditFieldBorder,
|
||||
modifier = Modifier.weight(1f, true),
|
||||
placeholder = {
|
||||
Text(
|
||||
@@ -382,18 +384,14 @@ fun EditFieldRow(
|
||||
onSendNewMessage()
|
||||
},
|
||||
isActive = channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage,
|
||||
modifier = Modifier
|
||||
.height(32.dp)
|
||||
.padding(end = 10.dp)
|
||||
modifier = EditFieldTrailingIconModifier
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
UploadFromGallery(
|
||||
isUploading = channelScreenModel.isUploadingImage,
|
||||
tint = MaterialTheme.colors.placeholderText,
|
||||
modifier = Modifier
|
||||
.height(32.dp)
|
||||
.padding(start = 2.dp)
|
||||
modifier = EditFieldLeadingIconModifier
|
||||
) {
|
||||
val fileServer = if (isPrivate) {
|
||||
// TODO: Make private servers
|
||||
@@ -606,25 +604,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 +883,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 +895,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 +929,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 +1099,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 +1121,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 +1142,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 +1164,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
|
||||
|
||||
@@ -26,13 +26,13 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
|
||||
@@ -49,26 +49,15 @@ fun ChatroomScreen(
|
||||
) {
|
||||
if (userId == null) return
|
||||
|
||||
var userRoom by remember(userId) { mutableStateOf<User?>(null) }
|
||||
|
||||
if (userRoom == null) {
|
||||
LaunchedEffect(userId) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newUser = LocalCache.checkGetOrCreateUser(userId)
|
||||
if (newUser != userRoom) {
|
||||
userRoom = newUser
|
||||
}
|
||||
}
|
||||
LoadUser(userId) {
|
||||
it?.let {
|
||||
PrepareChatroomViewModels(
|
||||
baseUser = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
userRoom?.let {
|
||||
PrepareChatroomViewModels(
|
||||
baseUser = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -49,6 +48,8 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -57,6 +58,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.ATag
|
||||
import com.vitorpamplona.amethyst.service.model.AppDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
@@ -78,6 +80,8 @@ 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
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrUserAppRecommendationsFeedViewModel
|
||||
@@ -93,10 +97,11 @@ 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
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -696,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 {
|
||||
@@ -748,7 +756,7 @@ private fun ProfileActions(
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.unfollow(baseUser)
|
||||
accountViewModel.account.unfollow(baseUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -767,7 +775,7 @@ private fun ProfileActions(
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.follow(baseUser)
|
||||
accountViewModel.account.follow(baseUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -785,7 +793,7 @@ private fun ProfileActions(
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.follow(baseUser)
|
||||
accountViewModel.account.follow(baseUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -793,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,
|
||||
@@ -967,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),
|
||||
@@ -1110,31 +1122,42 @@ private fun WatchApp(baseApp: Note, nav: (String) -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun DisplayBadges(
|
||||
baseUser: User,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val userBadgeState by baseUser.live().badges.observeAsState()
|
||||
val badgeList by remember(userBadgeState) {
|
||||
derivedStateOf {
|
||||
val list = (userBadgeState?.user?.latestAcceptedBadges?.event as? BadgeProfilesEvent)?.badgeAwardEvents()
|
||||
if (list.isNullOrEmpty()) {
|
||||
null
|
||||
} else {
|
||||
list.toImmutableList()
|
||||
LoadAddressableNote(
|
||||
aTag = ATag(
|
||||
BadgeProfilesEvent.kind,
|
||||
baseUser.pubkeyHex,
|
||||
BadgeProfilesEvent.standardDTAg,
|
||||
null
|
||||
)
|
||||
) {
|
||||
if (it != null) {
|
||||
val badgeList by it.live().metadata.map {
|
||||
(it.note.event as? BadgeProfilesEvent)?.badgeAwardEvents()?.toImmutableList()
|
||||
}.distinctUntilChanged().observeAsState()
|
||||
|
||||
badgeList?.let { list ->
|
||||
RenderBadgeList(list, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
badgeList?.let { list ->
|
||||
FlowRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 5.dp)
|
||||
) {
|
||||
list.forEach { badgeAwardEvent ->
|
||||
LoadAndRenderBadge(badgeAwardEvent, nav)
|
||||
}
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun RenderBadgeList(
|
||||
list: ImmutableList<String>,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
FlowRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 5.dp)
|
||||
) {
|
||||
list.forEach { badgeAwardEvent ->
|
||||
LoadAndRenderBadge(badgeAwardEvent, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1622,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()
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
@@ -85,10 +93,6 @@ fun VideoScreen(
|
||||
println("Video Start")
|
||||
NostrVideoDataSource.start()
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
println("Video Stop")
|
||||
NostrVideoDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
lifeCycleOwner.lifecycle.addObserver(observer)
|
||||
@@ -217,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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user