added first attempt to add user galleries. can read 10011 events and shows profile tab

What works:
- can load existing 10011 lists and show images in profile tab

What doesn't work:
- gallery view is broken
- can't load notes to click images to get to original note
- adding media to gallery crashes amethyst atm
- no functionality to delete media from gallery yet.
This commit is contained in:
Believethehype 2024-06-28 16:01:56 +02:00
parent e181296a91
commit 91caacd36d
17 changed files with 714 additions and 20 deletions

View File

@ -71,6 +71,7 @@ import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileServersEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GalleryListEvent
import com.vitorpamplona.quartz.events.GeneralListEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent
@ -2197,6 +2198,53 @@ class Account(
}
}
fun addToGallery(
idHex: String,
url: String,
) {
if (!isWriteable()) return
GalleryListEvent.addEvent(
userProfile().latestGalleryList,
idHex,
url,
signer,
) {
Client.send(it)
LocalCache.consume(it)
}
}
fun removeFromGallery(
note: Note,
url: String,
) {
if (!isWriteable()) return
val galleryentries = userProfile().latestGalleryList ?: return
if (note is AddressableNote) {
GalleryListEvent.removeReplaceable(
galleryentries,
note.address,
false,
signer,
) {
Client.send(it)
LocalCache.consume(it)
}
} else {
GalleryListEvent.removeEvent(
galleryentries,
note.idHex,
false,
signer,
) {
Client.send(it)
LocalCache.consume(it)
}
}
}
fun addBookmark(
note: Note,
isPrivate: Boolean,

View File

@ -75,6 +75,7 @@ import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileServersEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GalleryListEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.GitIssueEvent
@ -423,6 +424,19 @@ object LocalCache {
}
}
fun consume(event: GalleryListEvent) {
val user = getOrCreateUser(event.pubKey)
if (user.latestGalleryList == null || event.createdAt > user.latestGalleryList!!.createdAt) {
if (event.dTag() == "gallery") {
user.updateGallery(event)
}
// Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()}
// ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
}
fun formattedDateTime(timestamp: Long): String =
Instant
.ofEpochSecond(timestamp)
@ -2523,6 +2537,7 @@ object LocalCache {
is DraftEvent -> consume(event, relay)
is EmojiPackEvent -> consume(event, relay)
is EmojiPackSelectionEvent -> consume(event, relay)
is GalleryListEvent -> consume(event)
is GenericRepostEvent -> {
event.containedPost()?.let { verifyAndConsume(it, relay) }
consume(event)

View File

@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.GalleryListEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.ReportEvent
@ -60,6 +61,7 @@ class User(
var latestMetadataRelay: String? = null
var latestContactList: ContactListEvent? = null
var latestBookmarkList: BookmarkListEvent? = null
var latestGalleryList: GalleryListEvent? = null
var reports = mapOf<User, Set<Note>>()
private set
@ -123,6 +125,13 @@ class User(
liveSet?.innerBookmarks?.invalidateData()
}
fun updateGallery(event: GalleryListEvent) {
if (event.id == latestGalleryList?.id) return
print("GALLERY " + event.id())
latestGalleryList = event
liveSet?.innerGallery?.invalidateData()
}
fun clearEOSE() {
latestEOSEs = emptyMap()
}
@ -488,6 +497,7 @@ class UserLiveSet(
val innerRelayInfo = UserBundledRefresherLiveData(u)
val innerZaps = UserBundledRefresherLiveData(u)
val innerBookmarks = UserBundledRefresherLiveData(u)
val innerGallery = UserBundledRefresherLiveData(u)
val innerStatuses = UserBundledRefresherLiveData(u)
// UI Observers line up here.
@ -500,6 +510,7 @@ class UserLiveSet(
val relayInfo = innerRelayInfo.map { it }
val zaps = innerZaps.map { it }
val bookmarks = innerBookmarks.map { it }
val gallery = innerGallery.map { it }
val statuses = innerStatuses.map { it }
val profilePictureChanges = innerMetadata.map { it.user.profilePicture() }.distinctUntilChanged()
@ -517,7 +528,7 @@ class UserLiveSet(
relays.hasObservers() ||
relayInfo.hasObservers() ||
zaps.hasObservers() ||
bookmarks.hasObservers() ||
bookmarks.hasObservers() || gallery.hasObservers() ||
statuses.hasObservers() ||
profilePictureChanges.hasObservers() ||
nip05Changes.hasObservers() ||
@ -533,6 +544,7 @@ class UserLiveSet(
innerRelayInfo.destroy()
innerZaps.destroy()
innerBookmarks.destroy()
innerGallery.destroy()
innerStatuses.destroy()
}
}

View File

@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.events.BadgeAwardEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.GalleryListEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LnZapEvent
@ -146,7 +147,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
filter =
Filter(
kinds =
listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, AppRecommendationEvent.KIND),
listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, AppRecommendationEvent.KIND, GalleryListEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 100,
),

View File

@ -142,6 +142,7 @@ fun LoadThumbAndThenVideoView(
roundedCorner: Boolean,
isFiniteHeight: Boolean,
nostrUriCallback: String? = null,
nostrIdCallback: String? = null,
accountViewModel: AccountViewModel,
onDialog: ((Boolean) -> Unit)? = null,
) {
@ -176,6 +177,7 @@ fun LoadThumbAndThenVideoView(
artworkUri = thumbUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
nostrIdCallback = nostrIdCallback,
accountViewModel = accountViewModel,
onDialog = onDialog,
)
@ -190,6 +192,7 @@ fun LoadThumbAndThenVideoView(
artworkUri = thumbUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
nostrIdCallback = nostrIdCallback,
accountViewModel = accountViewModel,
onDialog = onDialog,
)
@ -211,6 +214,7 @@ fun VideoView(
dimensions: String? = null,
blurhash: String? = null,
nostrUriCallback: String? = null,
nostrIdCallback: String? = null,
onDialog: ((Boolean) -> Unit)? = null,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
@ -252,6 +256,7 @@ fun VideoView(
artworkUri = artworkUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
nostrIDCallback = nostrIdCallback,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog,
@ -305,6 +310,7 @@ fun VideoView(
artworkUri = artworkUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
nostrIDCallback = nostrIdCallback,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog,
@ -329,6 +335,7 @@ fun VideoViewInner(
artworkUri: String? = null,
authorName: String? = null,
nostrUriCallback: String? = null,
nostrIDCallback: String? = null,
automaticallyStartPlayback: State<Boolean>,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onDialog: ((Boolean) -> Unit)? = null,
@ -350,6 +357,7 @@ fun VideoViewInner(
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
nostrUriCallback = nostrUriCallback,
nostrIDCallback = nostrIDCallback,
waveform = waveform,
keepPlaying = keepPlaying,
automaticallyStartPlayback = automaticallyStartPlayback,
@ -697,6 +705,7 @@ private fun RenderVideoPlayer(
roundedCorner: Boolean,
isFiniteHeight: Boolean,
nostrUriCallback: String?,
nostrIDCallback: String?,
waveform: ImmutableList<Int>? = null,
keepPlaying: MutableState<Boolean>,
automaticallyStartPlayback: State<Boolean>,
@ -810,7 +819,7 @@ private fun RenderVideoPlayer(
}
AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle ->
ShareImageAction(popupExpanded, videoUri, nostrUriCallback, toggle)
ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, nostrIDCallback, toggle)
}
}
}

View File

@ -269,7 +269,7 @@ private fun DialogContent(
contentDescription = stringRes(R.string.quick_action_share),
)
ShareImageAction(popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false })
ShareImageAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false })
}
val localContext = LocalContext.current

View File

@ -138,6 +138,7 @@ fun ZoomableContentView(
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
nostrUriCallback = content.uri,
nostrIdCallback = content.id,
onDialog = { dialogOpen = true },
accountViewModel = accountViewModel,
)
@ -162,6 +163,7 @@ fun ZoomableContentView(
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
nostrUriCallback = content.uri,
nostrIdCallback = content.id,
onDialog = { dialogOpen = true },
accountViewModel = accountViewModel,
)
@ -596,22 +598,27 @@ fun DisplayBlurHash(
@Composable
fun ShareImageAction(
accountViewModel: AccountViewModel,
popupExpanded: MutableState<Boolean>,
content: BaseMediaContent,
onDismiss: () -> Unit,
) {
if (content is MediaUrlContent) {
ShareImageAction(
accountViewModel = accountViewModel,
popupExpanded = popupExpanded,
videoUri = content.url,
postNostrUri = content.uri,
postNostrid = content.id,
onDismiss = onDismiss,
)
} else if (content is MediaPreloadedContent) {
ShareImageAction(
accountViewModel = accountViewModel,
popupExpanded = popupExpanded,
videoUri = content.localFile?.toUri().toString(),
postNostrUri = content.uri,
postNostrid = content.id,
onDismiss = onDismiss,
)
}
@ -620,9 +627,11 @@ fun ShareImageAction(
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun ShareImageAction(
accountViewModel: AccountViewModel,
popupExpanded: MutableState<Boolean>,
videoUri: String?,
postNostrUri: String?,
postNostrid: String?,
onDismiss: () -> Unit,
) {
DropdownMenu(
@ -650,6 +659,23 @@ fun ShareImageAction(
},
)
}
postNostrUri?.let {
DropdownMenuItem(
text = { Text(stringRes(R.string.add_media_to_gallery)) },
onClick = {
if (videoUri != null) {
if (postNostrid != null) {
print("TODO")
print(postNostrid)
// TODO this still crashes
accountViewModel.account.addToGallery(postNostrid, videoUri)
}
}
onDismiss()
},
)
}
}
}

View File

@ -212,6 +212,7 @@ fun AudioHeader(
isFiniteHeight = isFiniteHeight,
accountViewModel = accountViewModel,
nostrUriCallback = note.toNostrUri(),
nostrIdCallback = note.idHex,
)
}
}

View File

@ -160,6 +160,7 @@ fun RenderLiveActivityEventInner(
isFiniteHeight = false,
accountViewModel = accountViewModel,
nostrUriCallback = "nostr:${baseNote.toNEvent()}",
nostrIdCallback = baseNote.idHex,
)
}
} else {

View File

@ -86,6 +86,7 @@ fun VideoDisplay(
val description = event.content.ifBlank { null } ?: event.alt()
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
val uri = note.toNostrUri()
val id = note.id()
val mimeType = event.mimeType()
mutableStateOf<BaseMediaContent>(

View File

@ -118,13 +118,6 @@ fun DiscoverScreen(
ScrollStateKeys.DISCOVER_CONTENT,
AppDefinitionEvent.KIND,
),
TabItem(
R.string.discover_marketplace,
discoveryMarketplaceFeedViewModel,
Route.Discover.base + "Marketplace",
ScrollStateKeys.DISCOVER_MARKETPLACE,
ClassifiedsEvent.KIND,
),
TabItem(
R.string.discover_live,
discoveryLiveFeedViewModel,
@ -139,6 +132,13 @@ fun DiscoverScreen(
ScrollStateKeys.DISCOVER_COMMUNITY,
CommunityDefinitionEvent.KIND,
),
TabItem(
R.string.discover_marketplace,
discoveryMarketplaceFeedViewModel,
Route.Discover.base + "Marketplace",
ScrollStateKeys.DISCOVER_MARKETPLACE,
ClassifiedsEvent.KIND,
),
TabItem(
R.string.discover_chat,
discoveryChatFeedViewModel,

View File

@ -0,0 +1,333 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.background
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.aspectRatio
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.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment.Companion.BottomStart
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.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.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport
import com.vitorpamplona.amethyst.ui.note.ClickableNote
import com.vitorpamplona.amethyst.ui.note.LongPressToQuickAction
import com.vitorpamplona.amethyst.ui.note.WatchAuthor
import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.ui.note.calculateBackgroundColor
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.quartz.events.GalleryListEvent
// TODO This is to large parts from the ChannelCardCompose
// Why does it not be in a grid, like the marketplace
@Composable
fun ProfileGallery(
baseNotes: List<GalleryThumb>,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
isHiddenFeed: Boolean = false,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
for (thumb in baseNotes) {
thumb.baseNote?.let {
WatchNoteEvent(baseNote = it, accountViewModel = accountViewModel) {
if (thumb.baseNote.event?.kind() == GalleryListEvent.KIND) {
CheckHiddenFeedWatchBlockAndReport(
note = thumb.baseNote,
modifier = modifier,
ignoreAllBlocksAndReports = isHiddenFeed,
showHiddenWarning = false,
accountViewModel = accountViewModel,
nav = nav,
) { canPreview ->
thumb.image?.let { it1 ->
GalleryCard(
baseNote = thumb.baseNote,
url = it1,
modifier = modifier,
parentBackgroundColor = parentBackgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
}
}
}
@Composable
fun GalleryCard(
baseNote: Note,
url: String,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
// baseNote.event?.let { Text(text = it.pubKey()) }
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
CheckNewAndRenderChannelCard(
baseNote,
url,
modifier,
parentBackgroundColor,
accountViewModel,
showPopup,
nav,
)
}
}
@Composable
private fun CheckNewAndRenderChannelCard(
baseNote: Note,
url: String,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
showPopup: () -> Unit,
nav: (String) -> Unit,
) {
val backgroundColor =
calculateBackgroundColor(
createdAt = baseNote.createdAt(),
parentBackgroundColor = parentBackgroundColor,
accountViewModel = accountViewModel,
)
ClickableNote(
baseNote = baseNote,
backgroundColor = backgroundColor,
modifier = modifier,
accountViewModel = accountViewModel,
showPopup = showPopup,
nav = nav,
) {
// baseNote.event?.let { Text(text = it.pubKey()) }
InnerGalleryCardWithReactions(
baseNote = baseNote,
url = url,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
fun InnerGalleryCardWithReactions(
baseNote: Note,
url: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
InnerGalleryCardBox(baseNote, url, accountViewModel, nav)
}
@Composable
fun InnerGalleryCardBox(
baseNote: Note,
url: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
Column(HalfPadding) {
SensitivityWarning(
note = baseNote,
accountViewModel = accountViewModel,
) {
RenderGalleryThumb(baseNote, url, accountViewModel, nav)
}
}
}
@Immutable
data class GalleryThumb(
val baseNote: Note?,
val id: String?,
val image: String?,
val title: String?,
// val price: Price?,
)
@Composable
fun RenderGalleryThumb(
baseNote: Note,
url: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val noteEvent = baseNote.event as? GalleryListEvent ?: return
val card by
baseNote
.live()
.metadata
.map {
val noteEvent = baseNote.event as GalleryListEvent
GalleryThumb(
baseNote = baseNote,
id = "",
image = url,
title = "Hello",
// noteEvent?.title(),
// price = noteEvent?.price(),
)
}.distinctUntilChanged()
.observeAsState(
GalleryThumb(
baseNote = baseNote,
id = "",
image = "https://gokaygokay-aurasr.hf.space/file=/tmp/gradio/68292f324a38d7071453cf6912dfb1da9d1305c8/image3.png",
title = "Hello",
// image = noteEvent.image(),
// title = noteEvent.title(),
// price = noteEvent.price(),
),
)
InnerRenderGalleryThumb(card as GalleryThumb, baseNote)
}
@Preview
@Composable
fun RenderGalleryThumbPreview() {
Surface(Modifier.size(200.dp)) {
InnerRenderGalleryThumb(
card =
GalleryThumb(
baseNote = null,
id = "",
image = null,
title = "Like New",
// price = Price("800000", "SATS", null),
),
note = Note("hex"),
)
}
}
@Composable
fun InnerRenderGalleryThumb(
card: GalleryThumb,
note: Note,
) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentAlignment = BottomStart,
) {
card.image?.let {
AsyncImage(
model = it,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} ?: run { DisplayGalleryAuthorBanner(note) }
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,
)
}*/
}
}
}
@Composable
fun DisplayGalleryAuthorBanner(note: Note) {
WatchAuthor(note) {
BannerImage(
it,
Modifier
.fillMaxSize()
.clip(QuoteBorder),
)
}
}

View File

@ -433,7 +433,8 @@ private fun RenderSurface(
}
}
},
).fillMaxHeight()
)
.fillMaxHeight()
},
) {
RenderScreen(
@ -532,13 +533,14 @@ private fun CreateAndRenderPages(
when (page) {
0 -> TabNotesNewThreads(threadsViewModel, accountViewModel, nav)
1 -> TabNotesConversations(repliesViewModel, accountViewModel, nav)
2 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav)
3 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav)
4 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav)
5 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav)
6 -> TabFollowedTags(baseUser, accountViewModel, nav)
7 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav)
8 -> TabRelays(baseUser, accountViewModel, nav)
2 -> Gallery(baseUser, followsFeedViewModel, accountViewModel, nav)
3 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav)
4 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav)
5 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav)
6 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav)
7 -> TabFollowedTags(baseUser, accountViewModel, nav)
8 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav)
9 -> TabRelays(baseUser, accountViewModel, nav)
}
}
@ -573,6 +575,7 @@ private fun CreateAndRenderTabs(
listOf<@Composable (() -> Unit)?>(
{ Text(text = stringRes(R.string.notes)) },
{ Text(text = stringRes(R.string.replies)) },
{ Text(text = "Gallery") },
{ FollowTabHeader(baseUser) },
{ FollowersTabHeader(baseUser) },
{ ZapTabHeader(baseUser) },
@ -1534,6 +1537,50 @@ fun TabNotesConversations(
}
}
@Composable
fun Gallery(
baseUser: User,
feedViewModel: UserFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
WatchFollowChanges(baseUser, feedViewModel)
Column(Modifier.fillMaxHeight()) {
Column {
baseUser.latestGalleryList?.let {
// val note2 = getOrCreateAddressableNoteInternal(aTag)
val note = LocalCache.getOrCreateAddressableNote(it.address())
note.event = it
var notes = listOf<GalleryThumb>()
for (tag in note.event?.tags()!!) {
if (tag.size > 2) {
if (tag[0] == "g") {
// TODO get the node by id on main thread. LoadNote does nothing.
val thumb =
GalleryThumb(
baseNote = note,
id = tag[2],
// TODO use the original note once it's loaded baseNote = basenote,
image = tag[1],
title = null,
)
notes = notes + thumb
// }
}
}
ProfileGallery(
baseNotes = notes,
modifier = Modifier,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
}
@Composable
fun TabFollowedTags(
baseUser: User,
@ -1545,7 +1592,11 @@ fun TabFollowedTags(
baseUser.latestContactList?.unverifiedFollowTagSet()
}
Column(Modifier.fillMaxHeight().padding(vertical = 0.dp)) {
Column(
Modifier
.fillMaxHeight()
.padding(vertical = 0.dp),
) {
items?.let {
LazyColumn {
itemsIndexed(items) { index, hashtag ->

View File

@ -586,6 +586,7 @@
<string name="share_or_save">Share or Save</string>
<string name="copy_url_to_clipboard">Copy URL to clipboard</string>
<string name="copy_the_note_id_to_the_clipboard">Copy Note ID to clipboard</string>
<string name="add_media_to_gallery">Add Media to Gallery</string>
<string name="created_at">Created at</string>
<string name="rules">Rules</string>

View File

@ -38,6 +38,7 @@ abstract class MediaUrlContent(
dim: String? = null,
blurhash: String? = null,
val uri: String? = null,
val id: String? = null,
val mimeType: String? = null,
) : BaseMediaContent(description, dim, blurhash)
@ -49,6 +50,7 @@ class MediaUrlImage(
blurhash: String? = null,
dim: String? = null,
uri: String? = null,
id: String? = null,
val contentWarning: String? = null,
mimeType: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
@ -60,6 +62,7 @@ class MediaUrlVideo(
hash: String? = null,
dim: String? = null,
uri: String? = null,
id: String? = null,
val artworkUri: String? = null,
val authorName: String? = null,
blurhash: String? = null,
@ -76,6 +79,7 @@ abstract class MediaPreloadedContent(
dim: String? = null,
blurhash: String? = null,
val uri: String,
val id: String? = null,
) : BaseMediaContent(description, dim, blurhash) {
fun localFileExists() = localFile != null && localFile.exists()
}

View File

@ -97,6 +97,7 @@ class EventFactory {
GitPatchEvent.KIND -> GitPatchEvent(id, pubKey, createdAt, tags, content, sig)
GitRepositoryEvent.KIND -> GitRepositoryEvent(id, pubKey, createdAt, tags, content, sig)
GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig)
GalleryListEvent.KIND -> GalleryListEvent(id, pubKey, createdAt, tags, content, sig)
HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
HTTPAuthorizationEvent.KIND ->
HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig)

View File

@ -0,0 +1,190 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class GalleryListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 10011
const val ALT = "Gallery List"
const val DEFAULT_D_TAG_GALLERY = "gallery"
fun addEvent(
earlierVersion: GalleryListEvent?,
eventId: HexKey,
url: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) = addTag(earlierVersion, "g", eventId, url, signer, createdAt, onReady)
fun addTag(
earlierVersion: GalleryListEvent?,
tagName: String,
tagValue: HexKey,
url: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) {
add(
earlierVersion,
arrayOf(arrayOf(tagName, url, tagValue)),
signer,
createdAt,
onReady,
)
}
fun add(
earlierVersion: GalleryListEvent?,
listNewTags: Array<Array<String>>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) {
create(
content = earlierVersion?.content ?: "",
tags = (earlierVersion?.tags ?: arrayOf(arrayOf("d", DEFAULT_D_TAG_GALLERY))).plus(listNewTags),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
fun removeEvent(
earlierVersion: GalleryListEvent,
eventId: HexKey,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) = removeTag(earlierVersion, "e", eventId, isPrivate, signer, createdAt, onReady)
fun removeReplaceable(
earlierVersion: GalleryListEvent,
aTag: ATag,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) = removeTag(earlierVersion, "a", aTag.toTag(), isPrivate, signer, createdAt, onReady)
private fun removeTag(
earlierVersion: GalleryListEvent,
tagName: String,
tagValue: HexKey,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) {
if (isPrivate) {
earlierVersion.privateTagsOrEmpty(signer) { privateTags ->
encryptTags(
privateTags =
privateTags
.filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) }
.toTypedArray(),
signer = signer,
) { encryptedTags ->
create(
content = encryptedTags,
tags =
earlierVersion.tags
.filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) }
.toTypedArray(),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
} else {
create(
content = earlierVersion.content,
tags =
earlierVersion.tags
.filter { it.size <= 1 || !(it[0] == tagName && it[1] == tagValue) }
.toTypedArray(),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
fun create(
content: String,
tags: Array<Array<String>>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) {
val newTags =
if (tags.any { it.size > 1 && it[0] == "alt" }) {
tags
} else {
tags + arrayOf("alt", ALT)
}
signer.sign(createdAt, KIND, newTags, content, onReady)
}
fun create(
name: String = "",
images: List<String>? = null,
videos: List<String>? = null,
audios: List<String>? = null,
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
tags.add(arrayOf("d", name))
images?.forEach { tags.add(arrayOf("image", it)) }
videos?.forEach { tags.add(arrayOf("video", it)) }
audios?.forEach { tags.add(arrayOf("audio", it)) }
tags.add(arrayOf("alt", ALT))
createPrivateTags(privEvents, privUsers, privAddresses, signer) { content ->
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)
}
}
}
}