Merge pull request #973 from believethehype/gallery_alternative_version

Profile Gallery
This commit is contained in:
Vitor Pamplona 2024-07-10 13:37:14 -04:00 committed by GitHub
commit 17109e7406
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1426 additions and 67 deletions

View File

@ -91,6 +91,7 @@ import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.Price
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.RelayAuthEvent
import com.vitorpamplona.quartz.events.ReportEvent
@ -2197,6 +2198,35 @@ class Account(
}
}
fun addToGallery(
idHex: String,
url: String,
relay: String?,
) {
if (!isWriteable()) return
ProfileGalleryEntryEvent.create(
url = url,
eventid = idHex,
/*magnetUri = magnetUri,
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
alt = alt,
originalHash = originalHash,
sensitiveContent = sensitiveContent, */
signer = signer,
) { event ->
Client.send(event)
LocalCache.consume(event, null)
}
}
fun removeFromGallery(note: Note) {
delete(note)
}
fun addBookmark(
note: Note,
isPrivate: Boolean,

View File

@ -103,6 +103,7 @@ import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.RecommendRelayEvent
import com.vitorpamplona.quartz.events.RelaySetEvent
@ -1668,6 +1669,26 @@ object LocalCache {
refreshObservers(note)
}
fun consume(
event: ProfileGalleryEntryEvent,
relay: Relay?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(
event: FileStorageHeaderEvent,
relay: Relay?,
@ -2529,6 +2550,7 @@ object LocalCache {
}
is FhirResourceEvent -> consume(event, relay)
is FileHeaderEvent -> consume(event, relay)
is ProfileGalleryEntryEvent -> consume(event, relay)
is FileServersEvent -> consume(event, relay)
is FileStorageEvent -> consume(event, relay)
is FileStorageHeaderEvent -> consume(event, relay)

View File

@ -139,6 +139,8 @@ open class Note(
var relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
private set
var associatedNote: Note? = null
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
fun id() = Hex.decode(idHex)

View File

@ -488,6 +488,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 +501,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()
@ -518,6 +520,7 @@ class UserLiveSet(
relayInfo.hasObservers() ||
zaps.hasObservers() ||
bookmarks.hasObservers() ||
gallery.hasObservers() ||
statuses.hasObservers() ||
profilePictureChanges.hasObservers() ||
nip05Changes.hasObservers() ||
@ -533,6 +536,7 @@ class UserLiveSet(
innerRelayInfo.destroy()
innerZaps.destroy()
innerBookmarks.destroy()
innerGallery.destroy()
innerStatuses.destroy()
}
}

View File

@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
@ -153,6 +154,20 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
)
}
fun createProfileGalleryFilter() =
user?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
kinds =
listOf(ProfileGalleryEntryEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1000,
),
)
}
fun createReceivedAwardsFilter() =
user?.let {
TypedFilter(
@ -173,6 +188,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
listOfNotNull(
createUserInfoFilter(),
createUserPostsFilter(),
createProfileGalleryFilter(),
createFollowFilter(),
createFollowersFilter(),
createUserReceivedZapsFilter(),

View File

@ -118,6 +118,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@ -204,6 +205,7 @@ fun VideoView(
title: String? = null,
thumb: VideoThumb? = null,
roundedCorner: Boolean,
gallery: Boolean = false,
isFiniteHeight: Boolean,
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
@ -247,6 +249,7 @@ fun VideoView(
title = title,
thumb = thumb,
roundedCorner = roundedCorner,
gallery = gallery,
isFiniteHeight = isFiniteHeight,
waveform = waveform,
artworkUri = artworkUri,
@ -324,6 +327,7 @@ fun VideoViewInner(
title: String? = null,
thumb: VideoThumb? = null,
roundedCorner: Boolean,
gallery: Boolean = false,
isFiniteHeight: Boolean,
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
@ -348,6 +352,7 @@ fun VideoViewInner(
controller = controller,
thumbData = thumb,
roundedCorner = roundedCorner,
gallery = gallery,
isFiniteHeight = isFiniteHeight,
nostrUriCallback = nostrUriCallback,
waveform = waveform,
@ -695,6 +700,7 @@ private fun RenderVideoPlayer(
controller: MediaController,
thumbData: VideoThumb?,
roundedCorner: Boolean,
gallery: Boolean = false,
isFiniteHeight: Boolean,
nostrUriCallback: String?,
waveform: ImmutableList<Int>? = null,
@ -712,13 +718,18 @@ private fun RenderVideoPlayer(
Box {
val borders = MaterialTheme.colorScheme.imageModifier
val bordersSquare = MaterialTheme.colorScheme.videoGalleryModifier
val myModifier =
remember(controller) {
if (roundedCorner) {
modifier.then(
borders.defaultMinSize(minHeight = 75.dp).align(Alignment.Center),
)
} else if (gallery) {
Modifier
modifier.then(
bordersSquare.defaultMinSize(minHeight = 75.dp).align(Alignment.Center),
)
} else {
modifier.fillMaxWidth().defaultMinSize(minHeight = 75.dp).align(Alignment.Center)
}
@ -737,6 +748,7 @@ private fun RenderVideoPlayer(
setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb())
controllerAutoShow = false
useController = !gallery
thumbData?.thumb?.let { defaultArtwork = it }
hideController()
resizeMode =
@ -745,72 +757,77 @@ private fun RenderVideoPlayer(
} else {
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
}
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
controller.pause()
innerOnDialog(it)
if (!gallery) {
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
controller.pause()
innerOnDialog(it)
}
}
setControllerVisibilityListener(
PlayerView.ControllerVisibilityListener { visible ->
controllerVisible.value = visible == View.VISIBLE
onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) }
},
)
}
setControllerVisibilityListener(
PlayerView.ControllerVisibilityListener { visible ->
controllerVisible.value = visible == View.VISIBLE
onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) }
},
)
}
},
)
waveform?.let { Waveform(it, controller, remember { Modifier.align(Alignment.Center) }) }
if (!gallery) {
val startingMuteState = remember(controller) { controller.volume < 0.001 }
val startingMuteState = remember(controller) { controller.volume < 0.001 }
MuteButton(
controllerVisible,
startingMuteState,
Modifier.align(Alignment.TopEnd),
) { mute: Boolean ->
// makes the new setting the default for new creations.
DEFAULT_MUTED_SETTING.value = mute
MuteButton(
controllerVisible,
startingMuteState,
Modifier.align(Alignment.TopEnd),
) { mute: Boolean ->
// makes the new setting the default for new creations.
DEFAULT_MUTED_SETTING.value = mute
// 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?.release()
keepPlayingMutex = null
}
controller.volume = if (mute) 0f else 1f
}
KeepPlayingButton(
keepPlaying,
controllerVisible,
Modifier.align(Alignment.TopEnd).padding(end = Size55dp),
) { 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) {
// 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?.release()
}
keepPlayingMutex = controller
} else {
if (keepPlayingMutex == controller) {
keepPlayingMutex = null
}
controller.volume = if (mute) 0f else 1f
}
keepPlaying.value = newKeepPlaying
}
KeepPlayingButton(
keepPlaying,
controllerVisible,
Modifier.align(Alignment.TopEnd).padding(end = Size55dp),
) { 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?.release()
}
keepPlayingMutex = controller
} else {
if (keepPlayingMutex == controller) {
keepPlayingMutex = null
}
}
AnimatedSaveButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size110dp)) { context ->
saveImage(videoUri, mimeType, context, accountViewModel)
}
keepPlaying.value = newKeepPlaying
}
AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle ->
ShareImageAction(popupExpanded, videoUri, nostrUriCallback, toggle)
AnimatedSaveButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size110dp)) { context ->
saveImage(videoUri, mimeType, context, accountViewModel)
}
AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle ->
ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, toggle)
}
} else {
controller.volume = 0f
}
}
}

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

@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.hashVerifierMark
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.toHexKey
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -596,12 +597,14 @@ 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,
@ -609,6 +612,7 @@ fun ShareImageAction(
)
} else if (content is MediaPreloadedContent) {
ShareImageAction(
accountViewModel = accountViewModel,
popupExpanded = popupExpanded,
videoUri = content.localFile?.toUri().toString(),
postNostrUri = content.uri,
@ -620,6 +624,7 @@ fun ShareImageAction(
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun ShareImageAction(
accountViewModel: AccountViewModel,
popupExpanded: MutableState<Boolean>,
videoUri: String?,
postNostrUri: String?,
@ -650,6 +655,23 @@ fun ShareImageAction(
},
)
}
postNostrUri?.let {
DropdownMenuItem(
text = { Text(stringRes(R.string.add_media_to_gallery)) },
onClick = {
if (videoUri != null) {
var n19 = Nip19Bech32.uriToRoute(postNostrUri)?.entity as? Nip19Bech32.NEvent
if (n19 != null) {
accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay[0]) // TODO Whole list or first?
accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery)
}
}
onDismiss()
},
)
}
}
}

View File

@ -0,0 +1,92 @@
/**
* 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.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.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent
class UserProfileGalleryFeedFilter(
val user: User,
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + "ProfileGallery"
override fun showHiddenKey(): Boolean =
account.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
account.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, it ->
acceptableEvent(it, params, user)
}
var sorted = sort(notes)
var finalnotes = setOf<Note>()
for (item in sorted) {
val note = (item.event as ProfileGalleryEntryEvent).event()?.let { LocalCache.checkGetOrCreateNote(it) }
if (note != null) {
note.associatedNote = item
finalnotes = finalnotes + note
}
}
return finalnotes.toList()
}
override fun applyFilter(collection: Set<Note>): Set<Note> = innerApplyFilter(collection)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
return collection.filterTo(HashSet()) { acceptableEvent(it, params, user) }
}
fun acceptableEvent(
it: Note,
params: FilterByListParams,
user: User,
): Boolean {
val noteEvent = it.event
return (
(it.event?.pubKey() == user.pubkeyHex && noteEvent is ProfileGalleryEntryEvent) && noteEvent.hasUrl() && noteEvent.hasEvent() // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))
) &&
params.match(noteEvent) &&
account.isAcceptable(it)
}
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.defaultStoriesFollowList.value,
followLists = account.liveStoriesFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
}

View File

@ -59,6 +59,7 @@ fun BlankNotePreview() {
fun BlankNote(
modifier: Modifier = Modifier,
idHex: String? = null,
shortPreview: Boolean = false,
) {
Column(modifier = modifier) {
Row {
@ -75,7 +76,12 @@ fun BlankNote(
horizontalArrangement = Arrangement.Center,
) {
Text(
text = stringRes(R.string.post_not_found) + if (idHex != null) ": $idHex" else "",
text =
if (shortPreview) {
stringRes(R.string.post_not_found_short)
} else {
stringRes(R.string.post_not_found) + if (idHex != null) ": $idHex" else ""
},
modifier = Modifier.padding(30.dp),
color = Color.Gray,
textAlign = TextAlign.Center,

View File

@ -154,6 +154,40 @@ fun LongPressToQuickAction(
}
}
@Composable
fun LongPressToQuickActionGallery(
baseNote: Note,
accountViewModel: AccountViewModel,
content: @Composable (() -> Unit) -> Unit,
) {
val popupExpanded = remember { mutableStateOf(false) }
content { popupExpanded.value = true }
if (popupExpanded.value) {
if (baseNote.author == accountViewModel.account.userProfile()) {
NoteQuickActionMenuGallery(
note = baseNote,
onDismiss = { popupExpanded.value = false },
accountViewModel = accountViewModel,
nav = {},
)
}
}
}
@Composable
fun NoteQuickActionMenuGallery(
note: Note,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
DeleteFromGalleryDialog(note, accountViewModel) {
onDismiss()
}
}
@Composable
fun NoteQuickActionMenu(
note: Note,
@ -435,6 +469,169 @@ private fun RenderMainPopup(
}
}
@Composable
private fun RenderDeleteFromGalleryPopup(
accountViewModel: AccountViewModel,
note: Note,
showDeleteAlertDialog: MutableState<Boolean>,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val primaryLight = lightenColor(MaterialTheme.colorScheme.primary, 0.1f)
val cardShape = RoundedCornerShape(5.dp)
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val backgroundColor =
if (MaterialTheme.colorScheme.isLight) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.secondaryButtonBackground
}
val showToast = { stringRes: Int ->
scope.launch {
Toast
.makeText(
context,
stringRes(context, stringRes),
Toast.LENGTH_SHORT,
).show()
}
}
val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) {
Card(
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape),
shape = cardShape,
colors = CardDefaults.cardColors(containerColor = backgroundColor),
) {
Column(modifier = Modifier.width(IntrinsicSize.Min)) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
NoteQuickActionItem(
icon = Icons.Default.ContentCopy,
label = stringRes(R.string.quick_action_copy_text),
) {
accountViewModel.decrypt(note) {
clipboardManager.setText(AnnotatedString(it))
showToast(R.string.copied_note_text_to_clipboard)
}
onDismiss()
}
VerticalDivider(color = primaryLight)
NoteQuickActionItem(
Icons.Default.AlternateEmail,
stringRes(R.string.quick_action_copy_user_id),
) {
note.author?.let {
scope.launch {
clipboardManager.setText(AnnotatedString(it.toNostrUri()))
showToast(R.string.copied_user_id_to_clipboard)
onDismiss()
}
}
}
VerticalDivider(color = primaryLight)
NoteQuickActionItem(
Icons.Default.FormatQuote,
stringRes(R.string.quick_action_copy_note_id),
) {
scope.launch {
clipboardManager.setText(AnnotatedString(note.toNostrUri()))
showToast(R.string.copied_note_id_to_clipboard)
onDismiss()
}
}
}
HorizontalDivider(
color = primaryLight,
)
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
if (isOwnNote) {
NoteQuickActionItem(
Icons.Default.Delete,
stringRes(R.string.quick_action_delete),
) {
if (accountViewModel.hideDeleteRequestDialog) {
accountViewModel.delete(note)
onDismiss()
} else {
showDeleteAlertDialog.value = true
}
}
} else if (isFollowingUser) {
NoteQuickActionItem(
Icons.Default.PersonRemove,
stringRes(R.string.quick_action_unfollow),
) {
accountViewModel.unfollow(note.author!!)
onDismiss()
}
} else {
NoteQuickActionItem(
Icons.Default.PersonAdd,
stringRes(R.string.quick_action_follow),
) {
accountViewModel.follow(note.author!!)
onDismiss()
}
}
VerticalDivider(color = primaryLight)
NoteQuickActionItem(
icon = ImageVector.vectorResource(id = R.drawable.relays),
label = stringRes(R.string.broadcast),
) {
accountViewModel.broadcast(note)
// showSelectTextDialog = true
onDismiss()
}
VerticalDivider(color = primaryLight)
if (isOwnNote && note.isDraft()) {
NoteQuickActionItem(
Icons.Default.Edit,
stringRes(R.string.edit_draft),
) {
onDismiss()
}
} else {
NoteQuickActionItem(
icon = Icons.Default.Share,
label = stringRes(R.string.quick_action_share),
) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note),
)
putExtra(
Intent.EXTRA_TITLE,
stringRes(context, R.string.quick_action_share_browser_link),
)
}
val shareIntent =
Intent.createChooser(
sendIntent,
stringRes(context, R.string.quick_action_share),
)
ContextCompat.startActivity(context, shareIntent, null)
onDismiss()
}
}
}
}
}
}
}
@Composable
fun NoteQuickActionItem(
icon: ImageVector,
@ -462,6 +659,25 @@ fun NoteQuickActionItem(
}
}
@Composable
fun DeleteFromGalleryDialog(
note: Note,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
QuickActionAlertDialogOneButton(
title = stringRes(R.string.quick_action_request_deletion_gallery_title),
textContent = stringRes(R.string.quick_action_request_deletion_gallery_alert_body),
buttonIcon = Icons.Default.Delete,
buttonText = stringRes(R.string.quick_action_delete_dialog_btn),
onClickDoOnce = {
accountViewModel.removefromMediaGallery(note)
onDismiss()
},
onDismiss = onDismiss,
)
}
@Composable
fun DeleteAlertDialog(
note: Note,
@ -612,3 +828,95 @@ fun QuickActionAlertDialog(
},
)
}
@Composable
fun QuickActionAlertDialogOneButton(
title: String,
textContent: String,
buttonIcon: ImageVector,
buttonText: String,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onClickDoOnce: () -> Unit,
onDismiss: () -> Unit,
) {
QuickActionAlertDialogOneButton(
title = title,
textContent = textContent,
icon = {
Icon(
imageVector = buttonIcon,
contentDescription = null,
)
},
buttonText = buttonText,
buttonColors = buttonColors,
onClickDoOnce = onClickDoOnce,
onDismiss = onDismiss,
)
}
@Composable
fun QuickActionAlertDialogOneButton(
title: String,
textContent: String,
buttonIconResource: Int,
buttonText: String,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onClickDoOnce: () -> Unit,
onDismiss: () -> Unit,
) {
QuickActionAlertDialogOneButton(
title = title,
textContent = textContent,
icon = {
Icon(
painter = painterResource(buttonIconResource),
contentDescription = null,
)
},
buttonText = buttonText,
buttonColors = buttonColors,
onClickDoOnce = onClickDoOnce,
onDismiss = onDismiss,
)
}
@Composable
fun QuickActionAlertDialogOneButton(
title: String,
textContent: String,
icon: @Composable () -> Unit,
buttonText: String,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onClickDoOnce: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(textContent) },
confirmButton = {
Row(
modifier =
Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Button(
onClick = onClickDoOnce,
colors = buttonColors,
contentPadding = PaddingValues(horizontal = 16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
icon()
Spacer(Modifier.width(8.dp))
Text(buttonText)
}
}
}
},
)
}

View File

@ -37,6 +37,7 @@ fun WatchNoteEvent(
baseNote: Note,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
shortPreview: Boolean = false,
onNoteEventFound: @Composable () -> Unit,
) {
WatchNoteEvent(
@ -54,6 +55,7 @@ fun WatchNoteEvent(
onLongClick = showPopup,
)
},
shortPreview = shortPreview,
)
}
},

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

@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileAppRecommendationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileGalleryFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
@ -248,6 +249,20 @@ class NostrUserProfileReportFeedViewModel(
}
}
class NostrUserProfileGalleryFeedViewModel(
val user: User,
val account: Account,
) : FeedViewModel(UserProfileGalleryFeedFilter(user, account)) {
class Factory(
val user: User,
val account: Account,
) : ViewModelProvider.Factory {
override fun <NostrUserProfileGalleryFeedViewModel : ViewModel> create(modelClass: Class<NostrUserProfileGalleryFeedViewModel>): NostrUserProfileGalleryFeedViewModel =
NostrUserProfileGalleryFeedViewModel(user, account)
as NostrUserProfileGalleryFeedViewModel
}
}
class NostrUserProfileBookmarksFeedViewModel(
val user: User,
val account: Account,

View File

@ -45,6 +45,7 @@ object ScrollStateKeys {
const val DISCOVER_SCREEN = "Discover"
val HOME_FOLLOWS = Route.Home.base + "Follows"
val HOME_REPLIES = Route.Home.base + "FollowsReplies"
val PROFILE_GALLERY = Route.Home.base + "ProfileGallery"
val DRAFTS = Route.Home.base + "Drafts"

View File

@ -669,6 +669,18 @@ class AccountViewModel(
viewModelScope.launch(Dispatchers.IO) { account.addEmojiPack(usersEmojiList, emojiList) }
}
fun addMediaToGallery(
hex: String,
url: String,
relay: String?,
) {
viewModelScope.launch(Dispatchers.IO) { account.addToGallery(hex, url, relay) }
}
fun removefromMediaGallery(note: Note) {
viewModelScope.launch(Dispatchers.IO) { account.removeFromGallery(note) }
}
fun addPrivateBookmark(note: Note) {
viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, true) }
}

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,370 @@
/**
* 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.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
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.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Surface
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.runtime.remember
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport
import com.vitorpamplona.amethyst.ui.note.ClickableNote
import com.vitorpamplona.amethyst.ui.note.LongPressToQuickActionGallery
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.screen.FeedEmpty
import com.vitorpamplona.amethyst.ui.screen.FeedError
import com.vitorpamplona.amethyst.ui.screen.FeedState
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent
@Composable
fun RenderGalleryFeed(
viewModel: FeedViewModel,
routeForLastRead: String?,
forceEventKind: Int?,
listState: LazyGridState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
CrossfadeIfEnabled(
targetState = feedState,
animationSpec = tween(durationMillis = 100),
label = "RenderDiscoverFeed",
accountViewModel = accountViewModel,
) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty { viewModel.invalidateData() }
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) { viewModel.invalidateData() }
}
is FeedState.Loaded -> {
GalleryFeedLoaded(
state,
routeForLastRead,
listState,
forceEventKind,
accountViewModel,
nav,
)
}
is FeedState.Loading -> {
LoadingFeed()
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun GalleryFeedLoaded(
state: FeedState.Loaded,
routeForLastRead: String?,
listState: LazyGridState,
forceEventKind: Int?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
contentPadding = FeedPadding,
state = listState,
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() }
Row(defaultModifier) {
GalleryCardCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
modifier = Modifier,
forceEventKind = forceEventKind,
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
@Composable
fun GalleryCardCompose(
baseNote: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
forceEventKind: Int?,
isHiddenFeed: Boolean = false,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, shortPreview = true) {
CheckHiddenFeedWatchBlockAndReport(
note = baseNote,
modifier = modifier,
ignoreAllBlocksAndReports = isHiddenFeed,
showHiddenWarning = false,
accountViewModel = accountViewModel,
nav = nav,
) { canPreview ->
if (baseNote.associatedNote != null) {
if (baseNote.associatedNote!!.event != null) {
val image = (baseNote.associatedNote!!.event as ProfileGalleryEntryEvent).url()
if (image != null) {
GalleryCard(
galleryNote = baseNote.associatedNote!!,
baseNote = baseNote,
image = image,
modifier = modifier,
parentBackgroundColor = parentBackgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
}
}
@Composable
fun GalleryCard(
galleryNote: Note,
baseNote: Note,
image: String,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
// baseNote.event?.let { Text(text = it.pubKey()) }
LongPressToQuickActionGallery(baseNote = galleryNote, accountViewModel = accountViewModel) { showPopup ->
CheckNewAndRenderChannelCard(
baseNote,
image,
modifier,
parentBackgroundColor,
accountViewModel,
showPopup,
nav,
)
}
}
@Composable
private fun CheckNewAndRenderChannelCard(
baseNote: Note,
image: 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,
) {
InnerGalleryCardBox(baseNote, image, accountViewModel, nav)
}
}
@Composable
fun InnerGalleryCardBox(
baseNote: Note,
image: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
Column(HalfPadding) {
SensitivityWarning(
note = baseNote,
accountViewModel = accountViewModel,
) {
RenderGalleryThumb(baseNote, image, accountViewModel, nav)
}
}
}
@Immutable
data class GalleryThumb(
val id: String?,
val image: String?,
val title: String?,
// val price: Price?,
)
@Composable
fun RenderGalleryThumb(
baseNote: Note,
image: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val card by
baseNote
.live()
.metadata
.map {
GalleryThumb(
id = "",
image = image,
title = "",
// noteEvent?.title(),
// price = noteEvent?.price(),
)
}.distinctUntilChanged()
.observeAsState(
GalleryThumb(
id = "",
image = image,
title = "",
),
)
InnerRenderGalleryThumb(card as GalleryThumb, baseNote, accountViewModel)
}
@Preview
@Composable
fun RenderGalleryThumbPreview(accountViewModel: AccountViewModel) {
Surface(Modifier.size(200.dp)) {
InnerRenderGalleryThumb(
card =
GalleryThumb(
id = "",
image = null,
title = "Like New",
// price = Price("800000", "SATS", null),
),
note = Note("hex"),
accountViewModel = accountViewModel,
)
}
}
@Composable
fun InnerRenderGalleryThumb(
card: GalleryThumb,
note: Note,
accountViewModel: AccountViewModel,
) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentAlignment = BottomStart,
) {
card.image?.let {
if (isVideoUrl(it)) {
VideoView(
videoUri = it,
mimeType = null,
title = "",
authorName = note.author?.toBestDisplayName(),
roundedCorner = false,
gallery = true,
isFiniteHeight = false,
alwaysShowVideo = true,
accountViewModel = accountViewModel,
)
} else {
AsyncImage(
model = it,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
?: run { DisplayGalleryAuthorBanner(note) }
}
}
@Composable
fun DisplayGalleryAuthorBanner(note: Note) {
WatchAuthor(note) {
BannerImage(
it,
Modifier
.fillMaxSize()
.clip(QuoteBorder),
)
}
}

View File

@ -148,13 +148,17 @@ import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileBookmarksFeedViewMod
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileConversationsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowersUserFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowsUserFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileGalleryFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileNewThreadsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileReportFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileZapsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableBox
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
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.SaveableGridFeedState
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@ -294,6 +298,16 @@ fun PrepareViewModels(
),
)
val galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel =
viewModel(
key = baseUser.pubkeyHex + "UserGalleryFeedViewModel",
factory =
NostrUserProfileGalleryFeedViewModel.Factory(
baseUser,
accountViewModel.account,
),
)
val reportsFeedViewModel: NostrUserProfileReportFeedViewModel =
viewModel(
key = baseUser.pubkeyHex + "UserProfileReportFeedViewModel",
@ -312,6 +326,7 @@ fun PrepareViewModels(
appRecommendations,
zapFeedViewModel,
bookmarksFeedViewModel,
galleryFeedViewModel,
reportsFeedViewModel,
accountViewModel = accountViewModel,
nav = nav,
@ -328,6 +343,7 @@ fun ProfileScreen(
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel,
galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel,
reportsFeedViewModel: NostrUserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@ -372,6 +388,7 @@ fun ProfileScreen(
followersFeedViewModel,
zapFeedViewModel,
bookmarksFeedViewModel,
galleryFeedViewModel,
reportsFeedViewModel,
accountViewModel,
nav,
@ -388,6 +405,7 @@ private fun RenderSurface(
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel,
galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel,
reportsFeedViewModel: NostrUserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@ -447,6 +465,7 @@ private fun RenderSurface(
followersFeedViewModel,
zapFeedViewModel,
bookmarksFeedViewModel,
galleryFeedViewModel,
reportsFeedViewModel,
accountViewModel,
nav,
@ -469,6 +488,7 @@ private fun RenderScreen(
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel,
galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel,
reportsFeedViewModel: NostrUserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@ -500,6 +520,7 @@ private fun RenderScreen(
followersFeedViewModel,
zapFeedViewModel,
bookmarksFeedViewModel,
galleryFeedViewModel,
reportsFeedViewModel,
accountViewModel,
nav,
@ -518,6 +539,7 @@ private fun CreateAndRenderPages(
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel,
galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel,
reportsFeedViewModel: NostrUserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@ -532,13 +554,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 -> TabGallery(galleryFeedViewModel, 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 +596,7 @@ private fun CreateAndRenderTabs(
listOf<@Composable (() -> Unit)?>(
{ Text(text = stringRes(R.string.notes)) },
{ Text(text = stringRes(R.string.replies)) },
{ Text(text = stringRes(R.string.gallery)) },
{ FollowTabHeader(baseUser) },
{ FollowersTabHeader(baseUser) },
{ ZapTabHeader(baseUser) },
@ -1534,6 +1558,77 @@ fun TabNotesConversations(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TabGallery(
feedViewModel: NostrUserProfileGalleryFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
LaunchedEffect(Unit) { feedViewModel.invalidateData() }
// Column(Modifier.fillMaxHeight()) {
RefresheableBox(feedViewModel, true) {
SaveableGridFeedState(feedViewModel, scrollStateKey = ScrollStateKeys.PROFILE_GALLERY) { listState ->
RenderGalleryFeed(
feedViewModel,
null,
0,
listState,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
// }
}
/*@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 +1640,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

@ -56,6 +56,7 @@ 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)

View File

@ -42,6 +42,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
@ -124,6 +125,18 @@ val LightImageModifier =
.clip(shape = QuoteBorder)
.border(1.dp, LightSubtleBorder, QuoteBorder)
val DarkVideoModifier =
Modifier
.fillMaxWidth()
.clip(shape = RectangleShape)
.border(1.dp, DarkSubtleBorder, RectangleShape)
val LightVideoModifier =
Modifier
.fillMaxWidth()
.clip(shape = RectangleShape)
.border(1.dp, LightSubtleBorder, RectangleShape)
val DarkProfile35dpModifier =
Modifier
.size(Size35dp)
@ -148,6 +161,20 @@ val LightReplyBorderModifier =
.clip(shape = QuoteBorder)
.border(1.dp, LightSubtleBorder, QuoteBorder)
val DarkVideoBorderModifier =
Modifier
.padding(top = 5.dp)
.fillMaxWidth()
.clip(shape = RectangleShape)
.border(1.dp, DarkSubtleBorder, RectangleShape)
val LightVideoBorderModifier =
Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RectangleShape)
.border(1.dp, LightSubtleBorder, RectangleShape)
val DarkInnerPostBorderModifier =
Modifier
.padding(vertical = 5.dp)
@ -356,6 +383,9 @@ val ColorScheme.markdownStyle: RichTextStyle
val ColorScheme.imageModifier: Modifier
get() = if (isLight) LightImageModifier else DarkImageModifier
val ColorScheme.videoGalleryModifier: Modifier
get() = if (isLight) LightVideoModifier else DarkVideoModifier
val ColorScheme.profile35dpModifier: Modifier
get() = if (isLight) LightProfile35dpModifier else DarkProfile35dpModifier

View File

@ -10,6 +10,7 @@
<string name="post_was_hidden">This post was hidden because it mentions your hidden users or words</string>
<string name="post_was_flagged_as_inappropriate_by">Post was muted or reported by</string>
<string name="post_not_found">Event is loading or can\'t be found in your relay list</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Channel Image</string>
<string name="referenced_event_not_found">Referenced event not found</string>
<string name="could_not_decrypt_the_message">Could not decrypt the message</string>
@ -135,6 +136,7 @@
<string name="conversations">Conversations</string>
<string name="notes">Notes</string>
<string name="replies">Replies</string>
<string name="gallery">Gallery</string>
<string name="follows">"Follows"</string>
<string name="reports">"Reports"</string>
<string name="more_options">More Options</string>
@ -271,6 +273,8 @@
<string name="quick_action_delete">Delete</string>
<string name="quick_action_unfollow">Unfollow</string>
<string name="quick_action_follow">Follow</string>
<string name="quick_action_request_deletion_gallery_title">Delete from Gallery</string>
<string name="quick_action_request_deletion_gallery_alert_body">Remove this media from your Gallery, you can readd it later</string>
<string name="quick_action_request_deletion_alert_title">Request Deletion</string>
<string name="quick_action_request_deletion_alert_body">Amethyst will request that your note be deleted from the relays you are currently connected to. There is no guarantee that your note will be permanently deleted from those relays, or from other relays where it may be stored.</string>
<string name="quick_action_block_dialog_btn">Block</string>
@ -598,6 +602,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

@ -85,6 +85,7 @@ class EventFactory {
EmojiPackSelectionEvent.KIND ->
EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig)
FileHeaderEvent.KIND -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
ProfileGalleryEntryEvent.KIND -> ProfileGalleryEntryEvent(id, pubKey, createdAt, tags, content, sig)
FileServersEvent.KIND -> FileServersEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageEvent.KIND -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageHeaderEvent.KIND ->

View File

@ -0,0 +1,169 @@
/**
* 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 = "Profile Gallery"
const val GALLERYTAGNAME = "url"
fun addEvent(
earlierVersion: GalleryListEvent?,
eventId: HexKey,
url: String,
relay: String?,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) = addTag(earlierVersion, GALLERYTAGNAME, eventId, url, relay, signer, createdAt, onReady)
fun addTag(
earlierVersion: GalleryListEvent?,
tagName: String,
eventid: HexKey,
url: String,
relay: String?,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) {
var tags = arrayOf(tagName, url, eventid)
if (relay != null) {
tags + relay
}
add(
earlierVersion,
arrayOf(tags),
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 = listNewTags.plus(earlierVersion?.tags ?: arrayOf()),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
fun removeEvent(
earlierVersion: GalleryListEvent,
eventId: HexKey,
url: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) = removeTag(earlierVersion, GALLERYTAGNAME, eventId, url, signer, createdAt, onReady)
fun removeReplaceable(
earlierVersion: GalleryListEvent,
aTag: ATag,
url: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) = removeTag(earlierVersion, GALLERYTAGNAME, aTag.toTag(), url, signer, createdAt, onReady)
private fun removeTag(
earlierVersion: GalleryListEvent,
tagName: String,
eventid: HexKey,
url: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) {
create(
content = earlierVersion.content,
tags =
earlierVersion.tags
.filter { it.size <= 1 || !(it[0] == tagName && it[1] == url && it[2] == eventid) }
.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)
}
}
@Immutable
data class GalleryUrl(
val url: String,
val id: String,
val relay: String?,
) {
fun encode(): String = ":$url:$id:$relay"
companion object {
fun decode(encodedGallerySetup: String): GalleryUrl? {
val galleryParts = encodedGallerySetup.split(":", limit = 3)
return if (galleryParts.size > 3) {
GalleryUrl(galleryParts[1], galleryParts[2], galleryParts[3])
} else {
null
}
}
}
}
}

View File

@ -0,0 +1,130 @@
/**
* 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.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class ProfileGalleryEntryEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1)
fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] }
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
fun hasUrl() = tags.any { it.size > 1 && it[0] == URL }
fun event() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun hasEvent() = tags.any { it.size > 1 && it[0] == "e" }
fun isOneOf(mimeTypes: Set<String>) = tags.any { it.size > 1 && it[0] == MIME_TYPE && mimeTypes.contains(it[1]) }
companion object {
const val KIND = 1163
const val ALT_DESCRIPTION = "Profile Gallery Entry"
const val URL = "url"
const val ENCRYPTION_KEY = "aes-256-gcm"
const val MIME_TYPE = "m"
const val FILE_SIZE = "size"
const val DIMENSION = "dim"
const val HASH = "x"
const val MAGNET_URI = "magnet"
const val TORRENT_INFOHASH = "i"
const val BLUR_HASH = "blurhash"
const val ORIGINAL_HASH = "ox"
const val ALT = "alt"
fun create(
url: String,
eventid: String? = null,
magnetUri: String? = null,
mimeType: String? = null,
alt: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
blurhash: String? = null,
originalHash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
sensitiveContent: Boolean? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ProfileGalleryEntryEvent) -> Unit,
) {
val tags =
listOfNotNull(
arrayOf(URL, url),
eventid?.let { arrayOf("e", it) },
magnetUri?.let { arrayOf(MAGNET_URI, it) },
mimeType?.let { arrayOf(MIME_TYPE, it) },
alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION),
hash?.let { arrayOf(HASH, it) },
size?.let { arrayOf(FILE_SIZE, it) },
dimensions?.let { arrayOf(DIMENSION, it) },
blurhash?.let { arrayOf(BLUR_HASH, it) },
originalHash?.let { arrayOf(ORIGINAL_HASH, it) },
magnetURI?.let { arrayOf(MAGNET_URI, it) },
torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { arrayOf(ENCRYPTION_KEY, it.key, it.nonce) },
sensitiveContent?.let {
if (it) {
arrayOf("content-warning", "")
} else {
null
}
},
)
val content = alt ?: ""
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)
}
}
}