diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c014c806e..33afb06b3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -130,7 +130,7 @@ class Account( var translateTo: String = Locale.getDefault().language, var zapAmountChoices: List = DefaultZapAmounts, var reactionChoices: List = DefaultReactions, - var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE, + var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0], var defaultHomeFollowList: MutableStateFlow = MutableStateFlow(KIND3_FOLLOWS), var defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), 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 abecb858f..b523c02da 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -72,6 +72,8 @@ import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.events.StatusEvent import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.VideoHorizontalEvent +import com.vitorpamplona.quartz.events.VideoVerticalEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList @@ -438,6 +440,9 @@ object LocalCache { private fun consume(event: PinListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } private fun consume(event: RelaySetEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } private fun consume(event: AudioTrackEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } + private fun consume(event: VideoVerticalEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } + private fun consume(event: VideoHorizontalEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) } + fun consume(event: StatusEvent, relay: Relay?) { val version = getOrCreateNote(event.id) val note = getOrCreateAddressableNote(event.address()) @@ -1593,7 +1598,8 @@ object LocalCache { } is StatusEvent -> consume(event, relay) is TextNoteEvent -> consume(event, relay) - + is VideoHorizontalEvent -> consume(event, relay) + is VideoVerticalEvent -> consume(event, relay) else -> { Log.w("Event Not Supported", event.toJson()) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 9edad0d63..d0d6bcd0a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -156,6 +156,7 @@ import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier import com.vitorpamplona.amethyst.ui.theme.boostedNoteModifier import com.vitorpamplona.amethyst.ui.theme.channelNotePictureModifier import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import com.vitorpamplona.amethyst.ui.theme.normalNoteModifier @@ -203,6 +204,9 @@ import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.UserMetadata +import com.vitorpamplona.quartz.events.VideoEvent +import com.vitorpamplona.quartz.events.VideoHorizontalEvent +import com.vitorpamplona.quartz.events.VideoVerticalEvent import com.vitorpamplona.quartz.events.toImmutableListOfLists import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -636,7 +640,7 @@ fun LongCommunityHeader( ) } - if (noteEvent.hasHashtags()) { + if (summary != null && noteEvent.hasHashtags()) { DisplayUncitedHashtags( remember(noteEvent) { noteEvent.hashtags().toImmutableList() }, summary ?: "", @@ -1171,6 +1175,14 @@ private fun RenderNoteRow( FileHeaderDisplay(baseNote, true, accountViewModel) } + is VideoHorizontalEvent -> { + VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + + is VideoVerticalEvent -> { + VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + is FileStorageHeaderEvent -> { FileStorageHeaderDisplay(baseNote, true, accountViewModel) } @@ -2384,7 +2396,21 @@ private fun ReplyRow( } if (showReply) { - val replyingDirectlyTo = remember { note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.kind } } + val replyingDirectlyTo = remember(note) { + if (noteEvent is BaseTextNoteEvent) { + val replyingTo = noteEvent.replyingTo() + if (replyingTo != null) { + note.replyTo?.firstOrNull() { + // important to test both ids in case it's a replaceable event. + it.idHex == replyingTo || it.event?.id() == replyingTo + } + } else { + note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.kind } + } + } else { + note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.kind } + } + } if (replyingDirectlyTo != null && unPackReply) { ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) Spacer(modifier = StdVertSpacer) @@ -3028,6 +3054,129 @@ fun FileHeaderDisplay(note: Note, roundedCorner: Boolean, accountViewModel: Acco } } +@Composable +fun VideoDisplay( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + val event = (note.event as? VideoEvent) ?: return + val fullUrl = event.url() ?: return + + val title = event.title() + val summary = event.content.ifBlank { null }?.takeIf { title != it } + val image = event.thumb() ?: event.image() + val isYouTube = fullUrl.contains("youtube.com") || fullUrl.contains("youtu.be") + val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + + val content by remember(note) { + val blurHash = event.blurhash() + val hash = event.hash() + val dimensions = event.dimensions() + val description = event.alt() ?: event.content + val isImage = imageExtensions.any { + removeQueryParamsForExtensionComparison(fullUrl).lowercase().endsWith(it) + } + val uri = note.toNostrUri() + + mutableStateOf( + if (isImage) { + ZoomableUrlImage( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri + ) + } else { + ZoomableUrlVideo( + url = fullUrl, + description = description, + hash = hash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName(), + artworkUri = event.thumb() ?: event.image() + ) + } + ) + } + + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 5.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isYouTube) { + val uri = LocalUriHandler.current + Row( + modifier = Modifier.clickable { runCatching { uri.openUri(fullUrl) } } + ) { + image?.let { + AsyncImage( + model = it, + contentDescription = stringResource( + R.string.preview_card_image_for, + it + ), + contentScale = ContentScale.FillWidth, + modifier = MaterialTheme.colorScheme.imageModifier + ) + } ?: CreateImageHeader(note, accountViewModel) + } + } else { + ZoomableContentView( + content = content, + roundedCorner = true, + accountViewModel = accountViewModel + ) + } + + title?.let { + Text( + text = it, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .padding(top = 5.dp) + ) + } + + summary?.let { + TranslatableRichTextViewer( + content = it, + canPreview = canPreview && !makeItShort, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav + ) + } + + if (event.hasHashtags()) { + Row( + Modifier.fillMaxWidth() + ) { + DisplayUncitedHashtags( + remember(event) { event.hashtags().toImmutableList() }, + summary ?: "", + nav + ) + } + } + } + } +} + @Composable fun FileStorageHeaderDisplay(baseNote: Note, roundedCorner: Boolean, accountViewModel: AccountViewModel) { val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 9b703746a..cf9112019 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -96,6 +96,7 @@ import com.vitorpamplona.amethyst.ui.note.RenderPoll import com.vitorpamplona.amethyst.ui.note.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.RenderRepost import com.vitorpamplona.amethyst.ui.note.RenderTextEvent +import com.vitorpamplona.amethyst.ui.note.VideoDisplay import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -130,6 +131,7 @@ import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.RelaySetEvent import com.vitorpamplona.quartz.events.RepostEvent +import com.vitorpamplona.quartz.events.VideoEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers @@ -423,6 +425,8 @@ fun NoteMaster( accountViewModel = accountViewModel, nav = nav ) + } else if (noteEvent is VideoEvent) { + VideoDisplay(baseNote, false, true, backgroundColor, accountViewModel, nav) } else if (noteEvent is FileHeaderEvent) { FileHeaderDisplay(baseNote, true, accountViewModel) } else if (noteEvent is FileStorageHeaderEvent) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt index 6ad366307..e27c86812 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BaseTextNoteEvent.kt @@ -23,6 +23,13 @@ open class BaseTextNoteEvent( fun mentions() = taggedUsers() open fun replyTos() = taggedEvents() + fun replyingTo(): HexKey? { + val oldStylePositional = tags.lastOrNull() { it.size > 1 && it[0] == "e" }?.get(1) + val newStyle = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1) + + return newStyle ?: oldStylePositional + } + @Transient private var citedUsersCache: Set? = null diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GoalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GoalEvent.kt index be49c6d8b..799dd3a33 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GoalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GoalEvent.kt @@ -39,7 +39,7 @@ class GoalEvent( createdAt: Long = TimeUtils.now(), onReady: (GoalEvent) -> Unit ) { - var tags = mutableListOf( + val tags = mutableListOf( arrayOf(AMOUNT, amount.toString()), arrayOf("relays") + relays, arrayOf("alt", alt) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt index c3da90f28..705bfc290 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt @@ -28,6 +28,11 @@ abstract class VideoEvent( 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 title() = tags.firstOrNull { it.size > 1 && it[0] == TITLE }?.get(1) + fun summary() = tags.firstOrNull { it.size > 1 && it[0] == SUMMARY }?.get(1) + fun image() = tags.firstOrNull { it.size > 1 && it[0] == IMAGE }?.get(1) + fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == THUMB }?.get(1) + fun hasUrl() = tags.any { it.size > 1 && it[0] == URL } companion object {