diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt index 92d9d7759..e37331872 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -20,11 +20,8 @@ class PublicChatChannel(idHex: String) : Channel(idHex) { var info = ChannelCreateEvent.ChannelData(null, null, null) fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) { - this.creator = creator this.info = channelInfo - this.updatedMetadataAt = updatedAt - - live.invalidateData() + super.updateChannelInfo(creator, updatedAt) } override fun toBestDisplayName(): String { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 9f9c62aa5..d0c7453c4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -781,7 +781,7 @@ object LocalCache { fun consume(event: ChannelMetadataEvent) { val channelId = event.channel() - // Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}") + // Log.d("MT", "New PublicChatMetadata ${event.channelInfo()}") if (channelId.isNullOrBlank()) return // new event @@ -789,10 +789,8 @@ object LocalCache { val author = getOrCreateUser(event.pubKey) if (event.createdAt > oldChannel.updatedMetadataAt) { - if (oldChannel.creator == null || oldChannel.creator == author) { - if (oldChannel is PublicChatChannel) { - oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) - } + if (oldChannel is PublicChatChannel) { + oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) } } else { // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt index a0a53815b..d8eca104c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/HttpClient.kt @@ -1,7 +1,12 @@ package com.vitorpamplona.amethyst.service import android.util.Log +import com.vitorpamplona.amethyst.BuildConfig +import okhttp3.Interceptor import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.IOException import java.net.InetSocketAddress import java.net.Proxy import java.time.Duration @@ -48,11 +53,24 @@ object HttpClient { .readTimeout(duration) .connectTimeout(duration) .writeTimeout(duration) + .addInterceptor(DefaultContentTypeInterceptor()) .followRedirects(true) .followSslRedirects(true) .build() } + class DefaultContentTypeInterceptor : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest: Request = chain.request() + val requestWithUserAgent: Request = originalRequest + .newBuilder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .build() + return chain.proceed(requestWithUserAgent) + } + } + fun getHttpClientForRelays(): OkHttpClient { if (this.defaultHttpClient == null) { this.defaultHttpClient = getHttpClient(defaultTimeout) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt index fd2463f77..a88dcc92b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt @@ -9,6 +9,7 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent +import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent @@ -42,6 +43,54 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { job?.cancel() } + fun createMarketplaceFilter(): List { + val follows = account.liveDiscoveryFollowLists.value?.users?.toList() + val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() + val geohashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() + + return listOfNotNull( + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + authors = follows, + kinds = listOf(ClassifiedsEvent.kind), + limit = 300, + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList + ) + ), + hashToLoad?.let { + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + kinds = listOf(ClassifiedsEvent.kind), + tags = mapOf( + "t" to it.map { + listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) + }.flatten() + ), + limit = 300, + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList + ) + ) + }, + geohashToLoad?.let { + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + kinds = listOf(ClassifiedsEvent.kind), + tags = mapOf( + "g" to it.map { + listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) + }.flatten() + ), + limit = 300, + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList + ) + ) + } + ) + } + fun createLiveStreamFilter(): List { val follows = account.liveDiscoveryFollowLists.value?.users?.toList() @@ -238,16 +287,19 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { } override fun updateChannelFilters() { - discoveryFeedChannel.typedFilters = createLiveStreamFilter().plus(createPublicChatFilter()).plus( - listOfNotNull( - createLiveStreamTagsFilter(), - createLiveStreamGeohashesFilter(), - createCommunitiesFilter(), - createPublicChatsTagsFilter(), - createCommunitiesTagsFilter(), - createCommunitiesGeohashesFilter(), - createPublicChatsGeohashesFilter() - ) - ).ifEmpty { null } + discoveryFeedChannel.typedFilters = createLiveStreamFilter() + .plus(createPublicChatFilter()) + .plus(createMarketplaceFilter()) + .plus( + listOfNotNull( + createLiveStreamTagsFilter(), + createLiveStreamGeohashesFilter(), + createCommunitiesFilter(), + createCommunitiesTagsFilter(), + createCommunitiesGeohashesFilter(), + createPublicChatsTagsFilter(), + createPublicChatsGeohashesFilter() + ) + ).ifEmpty { null } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index 26f22f9aa..e68d1dcd3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -1,10 +1,12 @@ package com.vitorpamplona.amethyst.ui.components +import android.content.Context import android.content.Intent import android.net.Uri import androidx.compose.animation.Crossfade import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -12,7 +14,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card import androidx.compose.material3.Divider +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -22,7 +26,6 @@ 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.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -35,21 +38,30 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat.startActivity +import androidx.lifecycle.viewmodel.compose.viewModel +import com.fasterxml.jackson.databind.node.TextNode import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.service.CashuProcessor import com.vitorpamplona.amethyst.service.CashuToken import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation import com.vitorpamplona.amethyst.ui.note.CashuIcon import com.vitorpamplona.amethyst.ui.note.CopyIcon +import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon import com.vitorpamplona.amethyst.ui.note.ZapIcon +import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size18Modifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.SmallishBorder import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.subtleBorder import kotlinx.coroutines.Dispatchers @@ -84,13 +96,45 @@ fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) { @Composable fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) { - val lud16 = remember(accountViewModel) { - accountViewModel.account.userProfile().info?.lud16 - } + CashuPreviewNew(token, accountViewModel::meltCashu, accountViewModel::toast) +} - val useWebService = false +@Composable +@Preview() +fun CashuPreviewPreview() { + val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() + + sharedPreferencesViewModel.init() + sharedPreferencesViewModel.updateTheme(ThemeType.DARK) + + AmethystTheme(sharedPrefsViewModel = sharedPreferencesViewModel) { + Column() { + CashuPreview( + token = CashuToken("token", "mint", 32400, TextNode("")), + melt = { token, context, onDone -> + }, + toast = { title, message -> + } + ) + + CashuPreviewNew( + token = CashuToken("token", "mint", 32400, TextNode("")), + melt = { token, context, onDone -> + }, + toast = { title, message -> + } + ) + } + } +} + +@Composable +fun CashuPreview( + token: CashuToken, + melt: (CashuToken, Context, (String, String) -> Unit) -> Unit, + toast: (String, String) -> Unit +) { val context = LocalContext.current - val scope = rememberCoroutineScope() val clipboardManager = LocalClipboardManager.current Column( @@ -148,28 +192,10 @@ fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) { Button( onClick = { - if (lud16 != null) { - scope.launch(Dispatchers.IO) { - isRedeeming = true - CashuProcessor().melt( - token, - lud16, - onSuccess = { title, message -> - isRedeeming = false - accountViewModel.toast(title, message) - }, - onError = { title, message -> - isRedeeming = false - accountViewModel.toast(title, message) - }, - context - ) - } - } else { - accountViewModel.toast( - context.getString(R.string.no_lightning_address_set), - context.getString(R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, accountViewModel.account.userProfile().toBestDisplayName()) - ) + isRedeeming = true + melt(token, context) { title, message -> + toast(title, message) + isRedeeming = false } }, shape = QuoteBorder, @@ -196,12 +222,12 @@ fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) { Button( onClick = { try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://$token")) + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}")) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK startActivity(context, intent, null) } catch (e: Exception) { - accountViewModel.toast("Cashu", context.getString(R.string.cashu_no_wallet_found)) + toast("Cashu", context.getString(R.string.cashu_no_wallet_found)) } }, shape = QuoteBorder, @@ -232,3 +258,111 @@ fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) { } } } + +@Composable +fun CashuPreviewNew( + token: CashuToken, + melt: (CashuToken, Context, (String, String) -> Unit) -> Unit, + toast: (String, String) -> Unit +) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp) + .clip(shape = QuoteBorder) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(10.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.cashu), + null, + modifier = Modifier.size(13.dp), + tint = Color.Unspecified + ) + + Text( + text = stringResource(R.string.cashu), + fontSize = 12.sp, + modifier = Modifier.padding(start = 5.dp, bottom = 1.dp) + ) + } + + Text( + text = "${token.totalAmount} ${stringResource(id = R.string.sats)}", + fontSize = 20.sp + ) + + Row(modifier = Modifier.padding(top = 5.dp)) { + var isRedeeming by remember { + mutableStateOf(false) + } + + FilledTonalButton( + onClick = { + isRedeeming = true + melt(token, context) { title, message -> + toast(title, message) + isRedeeming = false + } + }, + shape = SmallishBorder + ) { + if (isRedeeming) { + LoadingAnimation() + } else { + ZapIcon(Size20dp, tint = MaterialTheme.colorScheme.onBackground) + } + Spacer(StdHorzSpacer) + + Text( + "Redeem", + color = MaterialTheme.colorScheme.onBackground, + fontSize = 16.sp + ) + } + + Spacer(modifier = StdHorzSpacer) + + FilledTonalButton( + onClick = { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}")) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + + startActivity(context, intent, null) + } catch (e: Exception) { + toast("Cashu", context.getString(R.string.cashu_no_wallet_found)) + } + }, + shape = SmallishBorder, + contentPadding = PaddingValues(0.dp) + ) { + OpenInNewIcon(Size18Modifier, tint = MaterialTheme.colorScheme.onBackground) + } + + Spacer(modifier = StdHorzSpacer) + + FilledTonalButton( + onClick = { + // Copying the token to clipboard + clipboardManager.setText(AnnotatedString(token.token)) + }, + shape = SmallishBorder, + contentPadding = PaddingValues(0.dp) + ) { + CopyIcon(Size18Modifier, tint = MaterialTheme.colorScheme.onBackground) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt new file mode 100644 index 000000000..80a14c1f3 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt @@ -0,0 +1,72 @@ +package com.vitorpamplona.amethyst.ui.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.MuteListEvent +import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +open class DiscoverMarketplaceFeedFilter( + val account: Account +) : AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + followList() + } + + open fun followList(): String { + return account.defaultDiscoveryFollowList.value + } + + override fun showHiddenKey(): Boolean { + return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } + + override fun feed(): List { + val classifieds = LocalCache.addressables + .filter { it.value.event is ClassifiedsEvent } + .map { it.value } + + val notes = innerApplyFilter(classifieds) + + return sort(notes) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + protected open fun innerApplyFilter(collection: Collection): Set { + val now = TimeUtils.now() + val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS + val isHiddenList = showHiddenKey() + + val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() + val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() + val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() + + val activities = collection + .asSequence() + .filter { + it.event is ClassifiedsEvent && + it.event?.hasTagWithContent("image") == true && + it.event?.hasTagWithContent("price") == true && + it.event?.hasTagWithContent("title") == true + } + .filter { + isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true || it.event?.isTaggedGeoHashes(followingGeohashSet) == true + } + .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } + .filter { (it.createdAt() ?: 0) <= now } + .toSet() + + return activities + } + + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 380d5f2d6..0dbe56c64 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel @@ -61,6 +62,7 @@ fun AppNavigation( knownFeedViewModel: NostrChatroomListKnownFeedViewModel, newFeedViewModel: NostrChatroomListNewFeedViewModel, videoFeedViewModel: NostrVideoFeedViewModel, + discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, @@ -137,6 +139,7 @@ fun AppNavigation( Route.Discover.let { route -> composable(route.route, route.arguments, content = { DiscoverScreen( + discoveryMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel, discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, discoveryChatFeedViewModel = discoveryChatFeedViewModel, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index 2bd95649a..7b8559241 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.animation.Crossfade import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -15,8 +16,10 @@ 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.material3.Divider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -39,6 +42,7 @@ import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.distinctUntilChanged @@ -58,23 +62,28 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.HalfPadding import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.ChannelCreateEvent +import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_ENDED import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED import com.vitorpamplona.quartz.events.Participant +import com.vitorpamplona.quartz.events.Price import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -95,7 +104,7 @@ fun ChannelCardCompose( ) { val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) - Crossfade(targetState = hasEvent) { + Crossfade(targetState = hasEvent, label = "ChannelCardCompose") { if (it) { if (forceEventKind == null || baseNote.event?.kind() == forceEventKind) { CheckHiddenChannelCardCompose( @@ -155,7 +164,7 @@ fun CheckHiddenChannelCardCompose( note.isHiddenFor(it) }.distinctUntilChanged().observeAsState(accountViewModel.isNoteHidden(note)) - Crossfade(targetState = isHidden) { + Crossfade(targetState = isHidden, label = "CheckHiddenChannelCardCompose") { if (!it) { LoadedChannelCardCompose( note, @@ -195,7 +204,7 @@ fun LoadedChannelCardCompose( } } - Crossfade(targetState = state) { + Crossfade(targetState = state, label = "CheckHiddenChannelCardCompose") { RenderChannelCardReportState( it, note, @@ -220,7 +229,7 @@ fun RenderChannelCardReportState( ) { var showReportedNote by remember { mutableStateOf(false) } - Crossfade(targetState = !state.isAcceptable && !showReportedNote) { showHiddenNote -> + Crossfade(targetState = !state.isAcceptable && !showReportedNote, label = "CheckHiddenChannelCardCompose") { showHiddenNote -> if (showHiddenNote) { HiddenNote( state.relevantReports, @@ -334,6 +343,28 @@ fun InnerChannelCardWithReactions( baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit +) { + when (remember { baseNote.event }) { + is LiveActivitiesEvent -> { + InnerCardRow(baseNote, accountViewModel, nav) + } + is CommunityDefinitionEvent -> { + InnerCardRow(baseNote, accountViewModel, nav) + } + is ChannelCreateEvent -> { + InnerCardRow(baseNote, accountViewModel, nav) + } + is ClassifiedsEvent -> { + InnerCardBox(baseNote, accountViewModel, nav) + } + } +} + +@Composable +fun InnerCardRow( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit ) { Column(StdPadding) { SensitivityWarning( @@ -353,6 +384,22 @@ fun InnerChannelCardWithReactions( ) } +@Composable +fun InnerCardBox( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + Column(HalfPadding) { + SensitivityWarning( + note = baseNote, + accountViewModel = accountViewModel + ) { + RenderClassifiedsThumb(baseNote, accountViewModel, nav) + } + } +} + @Composable private fun RenderNoteRow( baseNote: Note, @@ -372,6 +419,116 @@ private fun RenderNoteRow( } } +@Immutable +data class ClassifiedsThumb( + val image: String?, + val title: String?, + val price: Price? +) + +@Composable +fun RenderClassifiedsThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + val noteEvent = baseNote.event as? ClassifiedsEvent ?: return + + val card by baseNote.live().metadata.map { + val noteEvent = it.note.event as? ClassifiedsEvent + + ClassifiedsThumb( + image = noteEvent?.image(), + title = noteEvent?.title(), + price = noteEvent?.price() + ) + }.distinctUntilChanged().observeAsState( + ClassifiedsThumb( + image = noteEvent.image(), + title = noteEvent.title(), + price = noteEvent.price() + ) + ) + + RenderClassifiedsThumb(card, baseNote.author) +} + +@Preview +@Composable +fun RenderClassifiedsThumbPreview() { + Surface(Modifier.size(200.dp)) { + RenderClassifiedsThumb( + card = ClassifiedsThumb( + image = null, + title = "Like New", + price = Price("800000", "SATS", null) + ), + author = null + ) + } +} + +@Composable +fun RenderClassifiedsThumb(card: ClassifiedsThumb, author: User?) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f), + contentAlignment = BottomStart + ) { + card.image?.let { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } ?: run { + author?.let { + DisplayAuthorBanner(it) + } + } + + Row( + Modifier + .fillMaxWidth() + .background(Color.Black.copy(0.6f)) + .padding(Size5dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + card.title?.let { + Text( + text = it, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White, + modifier = Modifier.weight(1f) + ) + } + + card.price?.let { + val priceTag = remember(card) { + val newAmount = it.amount.toBigDecimalOrNull()?.let { + showAmountAxis(it) + } ?: it.amount + + if (it.frequency != null && it.currency != null) { + "$newAmount ${it.currency}/${it.frequency}" + } else if (it.currency != null) { + "$newAmount ${it.currency}" + } else { + newAmount + } + } + + Text( + text = priceTag, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White + ) + } + } + } +} + @Immutable data class LiveActivityCard( val name: String, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt index 0d49582cf..bb750a37c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt @@ -13,6 +13,7 @@ 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.OpenInNew import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.filled.VolumeOff @@ -196,6 +197,16 @@ fun CopyIcon(modifier: Modifier, tint: Color = Color.Unspecified) { ) } +@Composable +fun OpenInNewIcon(modifier: Modifier, tint: Color = Color.Unspecified) { + Icon( + imageVector = Icons.Default.OpenInNew, + stringResource(id = R.string.copy_to_clipboard), + tint = tint, + modifier = modifier + ) +} + @Composable fun ExpandLessIcon(modifier: Modifier) { Icon( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index 3df1af741..dcf94aca0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Button @@ -64,7 +66,9 @@ fun RefresheableView( val modifier = remember { if (enablePullRefresh) { - Modifier.fillMaxSize().pullRefresh(pullRefreshState) + Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) } else { Modifier.fillMaxSize() } @@ -115,6 +119,23 @@ fun SaveableFeedState( content(listState) } +@Composable +fun SaveableGridFeedState( + viewModel: FeedViewModel, + scrollStateKey: String? = null, + content: @Composable (LazyGridState) -> Unit +) { + val gridState = if (scrollStateKey != null) { + rememberForeverLazyGridState(scrollStateKey) + } else { + rememberLazyGridState() + } + + WatchScrollToTop(viewModel, gridState) + + content(gridState) +} + @Composable private fun RenderFeed( viewModel: FeedViewModel, @@ -174,6 +195,21 @@ private fun WatchScrollToTop( } } +@Composable +private fun WatchScrollToTop( + viewModel: FeedViewModel, + listState: LazyGridState +) { + val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() + + LaunchedEffect(scrollToTop) { + if (scrollToTop > 0 && viewModel.scrolltoTopPending) { + listState.scrollToItem(index = 0) + viewModel.sentToTop() + } + } +} + @OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class) @Composable private fun FeedLoaded( @@ -190,7 +226,9 @@ private fun FeedLoaded( itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> val (value, elapsed) = measureTimedValue { val defaultModifier = remember { - Modifier.fillMaxWidth().animateItemPlacement() + Modifier + .fillMaxWidth() + .animateItemPlacement() } Row(defaultModifier) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index e46d3211a..6360e4dae 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.ui.dal.CommunityFeedFilter import com.vitorpamplona.amethyst.ui.dal.DiscoverChatFeedFilter import com.vitorpamplona.amethyst.ui.dal.DiscoverCommunityFeedFilter import com.vitorpamplona.amethyst.ui.dal.DiscoverLiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DiscoverMarketplaceFeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.GeoHashFeedFilter import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter @@ -72,6 +73,16 @@ class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFil } } +class NostrDiscoverMarketplaceFeedViewModel(val account: Account) : FeedViewModel( + DiscoverMarketplaceFeedFilter(account) +) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrDiscoverMarketplaceFeedViewModel { + return NostrDiscoverMarketplaceFeedViewModel(account) as NostrDiscoverMarketplaceFeedViewModel + } + } +} + class NostrDiscoverLiveFeedViewModel(val account: Account) : FeedViewModel(DiscoverLiveFeedFilter(account)) { class Factory(val account: Account) : ViewModelProvider.Factory { override fun create(modelClass: Class): NostrDiscoverLiveFeedViewModel { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt index 9f79daf5a..46b3b4983 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable @@ -22,6 +23,7 @@ object ScrollStateKeys { val HOME_FOLLOWS = Route.Home.base + "Follows" val HOME_REPLIES = Route.Home.base + "FollowsReplies" + val DISCOVER_MARKETPLACE = Route.Home.base + "Marketplace" val DISCOVER_LIVE = Route.Home.base + "Live" val DISCOVER_COMMUNITY = Route.Home.base + "Communities" val DISCOVER_CHATS = Route.Home.base + "Chats" @@ -32,6 +34,31 @@ object PagerStateKeys { const val DISCOVER_SCREEN = "PagerDiscover" } +@Composable +fun rememberForeverLazyGridState( + key: String, + initialFirstVisibleItemIndex: Int = 0, + initialFirstVisibleItemScrollOffset: Int = 0 +): LazyGridState { + val scrollState = rememberSaveable(saver = LazyGridState.Saver) { + val savedValue = savedScrollStates[key] + val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex + val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat() + LazyGridState( + savedIndex, + savedOffset.roundToInt() + ) + } + DisposableEffect(scrollState) { + onDispose { + val lastIndex = scrollState.firstVisibleItemIndex + val lastOffset = scrollState.firstVisibleItemScrollOffset + savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat()) + } + } + return scrollState +} + @Composable fun rememberForeverLazyListState( key: String, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index cd83ca014..a85b1e296 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.amethyst.model.RelayInformation import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.UserState +import com.vitorpamplona.amethyst.service.CashuProcessor +import com.vitorpamplona.amethyst.service.CashuToken import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.Nip05Verifier import com.vitorpamplona.amethyst.service.Nip11CachedRetriever @@ -1047,6 +1049,34 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View account.dismissPaymentRequest(request) } } + + fun meltCashu( + token: CashuToken, + context: Context, + onDone: (String, String) -> Unit + ) { + val lud16 = account.userProfile().info?.lud16 + if (lud16 != null) { + viewModelScope.launch(Dispatchers.IO) { + CashuProcessor().melt( + token, + lud16, + onSuccess = { title, message -> + onDone(title, message) + }, + onError = { title, message -> + onDone(title, message) + }, + context + ) + } + } else { + onDone( + context.getString(R.string.no_lightning_address_set), + context.getString(R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, account.userProfile().toBestDisplayName()) + ) + } + } } class HasNotificationDot(bottomNavigationItems: ImmutableList) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt index 596871b5f..e137e82cd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -18,8 +19,8 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -47,14 +48,17 @@ import com.vitorpamplona.amethyst.ui.screen.LoadingFeed import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel import com.vitorpamplona.amethyst.ui.screen.PagerStateKeys import com.vitorpamplona.amethyst.ui.screen.RefresheableView import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState +import com.vitorpamplona.amethyst.ui.screen.SaveableGridFeedState import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.quartz.events.ChannelCreateEvent +import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import kotlinx.collections.immutable.ImmutableList @@ -64,6 +68,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun DiscoverScreen( + discoveryMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, @@ -75,6 +80,7 @@ fun DiscoverScreen( val tabs by remember(discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel, discoveryChatFeedViewModel) { mutableStateOf( listOf( + TabItem(R.string.discover_marketplace, discoveryMarketplaceFeedViewModel, Route.Discover.base + "Marketplace", ScrollStateKeys.DISCOVER_MARKETPLACE, ClassifiedsEvent.kind), 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) @@ -85,6 +91,7 @@ fun DiscoverScreen( val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { tabs.size } WatchAccountForDiscoveryScreen( + discoverMarketplaceFeedViewModel = discoveryMarketplaceFeedViewModel, discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, discoveryChatFeedViewModel = discoveryChatFeedViewModel, @@ -122,11 +129,12 @@ private fun DiscoverPages( accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - TabRow( + ScrollableTabRow( containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, selectedTabIndex = pagerState.currentPage, - modifier = TabRowHeight + modifier = TabRowHeight, + edgePadding = 8.dp ) { val coroutineScope = rememberCoroutineScope() @@ -145,15 +153,28 @@ private fun DiscoverPages( HorizontalPager(state = pagerState) { page -> RefresheableView(tabs[page].viewModel, true) { - SaveableFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState -> - RenderDiscoverFeed( - viewModel = tabs[page].viewModel, - routeForLastRead = tabs[page].routeForLastRead, - forceEventKind = tabs[page].forceEventKind, - listState = listState, - accountViewModel = accountViewModel, - nav = nav - ) + if (tabs[page].viewModel is NostrDiscoverMarketplaceFeedViewModel) { + SaveableGridFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState -> + RenderDiscoverFeed( + viewModel = tabs[page].viewModel, + routeForLastRead = tabs[page].routeForLastRead, + forceEventKind = tabs[page].forceEventKind, + listState = listState, + accountViewModel = accountViewModel, + nav = nav + ) + } + } else { + SaveableFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState -> + RenderDiscoverFeed( + viewModel = tabs[page].viewModel, + routeForLastRead = tabs[page].routeForLastRead, + forceEventKind = tabs[page].forceEventKind, + listState = listState, + accountViewModel = accountViewModel, + nav = nav + ) + } } } } @@ -164,7 +185,7 @@ private fun RenderDiscoverFeed( viewModel: FeedViewModel, routeForLastRead: String?, forceEventKind: Int?, - listState: LazyListState, + listState: ScrollableState, accountViewModel: AccountViewModel, nav: (String) -> Unit ) { @@ -189,14 +210,25 @@ private fun RenderDiscoverFeed( } is FeedState.Loaded -> { - DiscoverFeedLoaded( - state, - routeForLastRead, - listState, - forceEventKind, - accountViewModel, - nav - ) + if (listState is LazyGridState) { + DiscoverFeedColumnsLoaded( + state, + routeForLastRead, + listState, + forceEventKind, + accountViewModel, + nav + ) + } else if (listState is LazyListState) { + DiscoverFeedLoaded( + state, + routeForLastRead, + listState, + forceEventKind, + accountViewModel, + nav + ) + } } is FeedState.Loading -> { @@ -208,6 +240,7 @@ private fun RenderDiscoverFeed( @Composable fun WatchAccountForDiscoveryScreen( + discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, @@ -217,6 +250,7 @@ fun WatchAccountForDiscoveryScreen( LaunchedEffect(accountViewModel, listState) { NostrDiscoveryDataSource.resetFilters() + discoverMarketplaceFeedViewModel.checkKeysInvalidateDataAndSendToTop() discoveryLiveFeedViewModel.checkKeysInvalidateDataAndSendToTop() discoveryCommunityFeedViewModel.checkKeysInvalidateDataAndSendToTop() discoveryChatFeedViewModel.checkKeysInvalidateDataAndSendToTop() @@ -260,7 +294,7 @@ private fun DiscoverFeedLoaded( @OptIn(ExperimentalFoundationApi::class) @Composable -private fun DiscoverFeedTwoColumnsLoaded( +private fun DiscoverFeedColumnsLoaded( state: FeedState.Loaded, routeForLastRead: String?, listState: LazyGridState, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 14810b779..0419964f9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel @@ -169,6 +170,11 @@ fun MainScreen( factory = NostrVideoFeedViewModel.Factory(accountViewModel.account) ) + val discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel = viewModel( + key = "NostrDiscoveryMarketplaceFeedViewModel", + factory = NostrDiscoverMarketplaceFeedViewModel.Factory(accountViewModel.account) + ) + val discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel = viewModel( key = "NostrDiscoveryLiveFeedViewModel", factory = NostrDiscoverLiveFeedViewModel.Factory(accountViewModel.account) @@ -364,6 +370,7 @@ fun MainScreen( knownFeedViewModel = knownFeedViewModel, newFeedViewModel = newFeedViewModel, videoFeedViewModel = videoFeedViewModel, + discoverMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel, discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, discoveryChatFeedViewModel = discoveryChatFeedViewModel, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index f9a7ede87..d719ec38d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -27,6 +27,7 @@ val BottomTopHeight = Modifier.height(50.dp) val TabRowHeight = Modifier val SmallBorder = RoundedCornerShape(7.dp) +val SmallishBorder = RoundedCornerShape(9.dp) val QuoteBorder = RoundedCornerShape(15.dp) val ButtonBorder = RoundedCornerShape(20.dp) val EditFieldBorder = RoundedCornerShape(25.dp) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 48e0a95a5..bd07c08a5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -37,17 +37,17 @@ import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel private val DarkColorPalette = darkColorScheme( primary = Purple200, secondary = Teal200, - // secondary = Purple700, tertiary = Teal200, background = Color(0xFF000000), - surface = Color(0xFF000000) + surface = Color(0xFF000000), + surfaceVariant = Color(red = 29, green = 26, blue = 34) ) private val LightColorPalette = lightColorScheme( primary = Purple500, secondary = Teal200, - // secondary = Purple700, - tertiary = Teal200 + tertiary = Teal200, + surfaceVariant = Color(red = 250, green = 245, blue = 252) ) private val DarkNewItemBackground = DarkColorPalette.primary.copy(0.12f) diff --git a/app/src/main/res/drawable/cashu.xml b/app/src/main/res/drawable/cashu.xml index e08c5f079..46f884c4f 100644 --- a/app/src/main/res/drawable/cashu.xml +++ b/app/src/main/res/drawable/cashu.xml @@ -15,11 +15,6 @@ android:fillType="evenOdd" android:strokeWidth="1" android:pathData="M10.8,131.8 C10.392,132.291,10.42,132.4,10.951,132.4 C11.308,132.4,11.6,132.13,11.6,131.8 C11.6,131.47,11.532,131.2,11.449,131.2 C11.366,131.2,11.074,131.47,10.8,131.8 M58.237,133.448 C57.887,134.36,57.961,147.319,58.319,147.883 C58.512,148.186,59.963,148.41,62.213,148.483 L65.8,148.6 L65.917,152.179 C66.06,156.561,66.194,156.735,69.534,156.883 L72.2,157 L72.3,158.6 C72.666,164.449,72.746,164.553,77.084,164.8 L80.6,165 L80.8,167.6 C80.91,169.03,81.146,170.425,81.324,170.7 C81.746,171.353,133.611,171.436,134.826,170.786 C135.457,170.449,135.6,169.936,135.6,168.019 C135.6,164.388,135.587,164.4,139.394,164.4 C143.498,164.4,143.705,164.196,143.988,159.853 L144.2,156.6 L147.2,156.499 C152.502,156.32,152,157.187,152,148.206 C152,138.834,151.333,139.6,159.494,139.6 C167.001,139.6,166.4,138.869,166.4,148 C166.4,156.776,166.212,156.398,170.58,156.406 C174.674,156.414,174.678,156.418,174.858,160.045 C175.05,163.889,175.446,164.4,178.235,164.4 C180.806,164.4,181.2,164.9,181.2,168.16 C181.2,171.26,179.612,171.093,208.639,171.044 C238.254,170.994,236,171.283,236,167.541 C236,164.723,236.477,164.4,240.636,164.4 C243.695,164.4,244,163.988,244,159.854 C244,156.611,244.258,156.4,248.231,156.4 C252.25,156.4,252.332,156.315,252.483,151.98 L252.6,148.6 L255.965,148.295 C257.815,148.128,259.57,147.791,259.865,147.546 C260.608,146.929,260.64,134.288,259.9,133.703 C258.957,132.958,58.522,132.705,58.237,133.448 M81.117,143.379 C81.265,147.936,81.325,148,85.4,148 C89.475,148,89.535,147.936,89.683,143.379 L89.8,139.8 L92.895,139.683 C97.42,139.513,97.6,139.676,97.6,143.967 C97.6,147.999,97.586,147.984,101.42,147.995 C104.178,148.002,104.4,148.337,104.4,152.48 L104.4,156 L107.869,156 C112.781,156,113.783,157.444,112.379,162.5 L111.962,164 L108.405,164 C104.197,164,104.304,164.106,104.073,159.699 L103.898,156.368 L100.849,156.484 L97.8,156.6 L97.577,159.749 C97.301,163.639,96.925,164,93.147,164 C89.898,164,89.86,163.954,89.6,159.8 L89.4,156.6 L85.737,156.485 C81.274,156.346,81.265,156.337,81.117,151.779 L81,148.2 L77.6,147.991 C73.119,147.716,73.2,147.793,73.2,143.796 C73.2,139.702,73.348,139.57,77.8,139.704 L81,139.8 L81.117,143.379 M191.517,143.379 C191.659,147.734,191.848,148,194.8,148 C197.752,148,197.941,147.734,198.083,143.379 L198.2,139.8 L201.74,139.684 C206.333,139.533,206.4,139.595,206.4,144.006 C206.4,148.115,206.274,148,210.753,148 C214.691,148,214.8,148.12,214.8,152.48 L214.8,156 L217.181,156 C221.079,156,221.6,156.422,221.6,159.575 C221.6,163.374,221.062,164,217.801,164 C214.722,164,214.697,163.968,214.474,159.715 L214.3,156.4 L210.35,156.4 L206.4,156.4 L206.4,159.174 C206.4,163.812,206.238,164,202.234,164 C198.214,164,198.266,164.051,198,159.8 L197.8,156.6 L195.134,156.483 C191.794,156.335,191.66,156.161,191.517,151.779 L191.4,148.2 L187.821,148.083 C183.269,147.935,183.2,147.872,183.2,143.831 C183.2,139.617,183.281,139.544,187.794,139.686 L191.4,139.8 L191.517,143.379 M89.776,148.518 C89.66,148.82,89.618,150.582,89.683,152.433 L89.8,155.8 L93.515,155.915 L97.229,156.03 L97.115,152.115 L97,148.2 L93.493,148.084 C90.809,147.996,89.938,148.097,89.776,148.518 M198.17,148.533 C198.058,148.827,198.018,150.582,198.083,152.433 L198.2,155.8 L202.1,155.914 L206,156.029 L206,152.014 L206,148 L202.187,148 C199.379,148,198.321,148.14,198.17,148.533 M364.08,318.08 C363.816,318.344,363.6,318.698,363.6,318.867 C363.6,319.365,364.718,318.79,364.957,318.169 C365.223,317.475,364.735,317.425,364.08,318.08" /> - Betaling Cashu token Inwisselen + Stuur naar Zap wallet + Openen in Cashu wallet + Token kopiëren Geen Lightning-adres ingesteld Token gekopieerd naar klembord LIVE @@ -475,6 +478,7 @@ Video\'s en GIF\'s automatisch afspelen Toon URL-voorbeelden Wanneer afbeeldingen te laden + Kopiëren naar klembord Kopieer URL naar klembord Note naar klembord kopiëren Gemaakt op @@ -530,6 +534,7 @@ Cashu tokens zijn al uitgegeven. Cashu ontvangen %1$s sats zijn naar uw wallet gestuurd. (Fees: %2$s sats) + Geen compatibele Cashu wallet gevonden in het systeem Kan invoice niet ophalen van de servers van de ontvanger De volgende fout is geretourneerd door uw wallet connect provider: %1$s Kan geen verbinding maken met Tor @@ -561,4 +566,37 @@ Stuur de verkoper een bericht Hallo %1$s, is dit nog steeds beschikbaar? Hallo daar, is dit nog steeds beschikbaar? + Verkoop een artikel + Titel + iPhone 13 + Staat + Categorie + Prijs (in sats) + 1000 + Locatie + Stad, provincie, land + Nieuw + Het is een gloednieuw artikel, in de originele doos + Als nieuw + Gebruikt, maar er zijn geen gebruikerssporen + Goed + Het heeft een aantal oppervlakkige gebruikerssporen + Redelijk + Het is nog steeds in acceptabele en functionele vorm + Kleding + Accessoires + Electronica + Meubels + Verzamelobjecten + Boeken + Huisdieren + Sport + Fitness + Kunst + Ambachten + Startpagina + Kantoor + Eten + Overige + Anders diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b64b6c0d8..b77091eec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -489,6 +489,7 @@ Relays + Marketplace Live Community Chats diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index b93eddbb9..62494293d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -64,7 +64,9 @@ open class Event( override fun toJson(): String = mapper.writeValueAsString(toJsonObject()) - override fun hasAnyTaggedUser() = tags.any { it.size > 1 && it[0] == "p" } + override fun hasAnyTaggedUser() = hasTagWithContent("p") + + override fun hasTagWithContent(tagName: String) = tags.any { it.size > 1 && it[0] == tagName } 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] } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt index ed4cbd8f9..0d2747f25 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt @@ -68,6 +68,7 @@ interface EventInterface { fun zapraiserAmount(): Long? fun hasAnyTaggedUser(): Boolean + fun hasTagWithContent(tagName: String): Boolean fun taggedAddresses(): List fun taggedUsers(): List