feat: share images, shorts and videos straight into their feeds

Adds three share targets next to "New Post", "Send as DM" and
"New Highlight":

- New Picture (image/*) -> the picture feed's composer, publishing a
  NIP-68 kind 20 picture post
- New Short (video/*)   -> the Shorts feed's composer, publishing a
  NIP-71 kind 22 short
- New Video (video/*)   -> the Video feed's composer, publishing a
  NIP-71 video event

Until now every SEND intent landed in the kind-1 composer, so a shared
picture became a text note with a link instead of a post in the feed
the user was aiming for.

Each alias resolves to MainActivity like the existing ones, so
ShareIntentRouting now maps the launching component class to a
ShareTarget enum instead of a growing chain of isShareAsX() booleans.
The media targets navigate to the destination feed carrying the shared
content URI, and the feed's existing composer button opens on it.

Video kind is no longer guessed from orientation alone in feeds that
only read one of the two kinds: the Shorts composer always publishes
kind 22 and the Longs composer always publishes kind 21, so a post
lands in the feed it was composed from whatever the footage's shape.
The mixed Video feed and the picture feed keep the automatic choice.

Also fixes the New Post launch path passing the literal string "null"
as the attachment when a share carried no media.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4WsYWaNMPD34SBc6Ej6hx
This commit is contained in:
Claude
2026-07-30 15:11:56 +00:00
parent 1f4d9728be
commit ea2472468b
19 changed files with 424 additions and 90 deletions
+51
View File
@@ -295,6 +295,57 @@
</intent-filter>
</activity-alias>
<!-- "New Picture" share target: a gallery (or camera) shares an image and it goes straight
into the picture feed as a NIP-68 kind-20 post instead of a text note with a link. The
android:name simple class ("ShareAsPictureAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsPictureAlias"
android:exported="true"
android:label="@string/share_target_as_picture"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_picture">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity-alias>
<!-- "New Short" share target: a video shared here always becomes a NIP-71 kind-22 short so
it lands in the Shorts feed, whatever its orientation. Video-only — a short is a video.
The android:name simple class ("ShareAsShortVideoAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsShortVideoAlias"
android:exported="true"
android:label="@string/share_target_as_short_video"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_short_video">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
</activity-alias>
<!-- "New Video" share target: opens the Video feed's media composer, which publishes a
NIP-71 video event (kind 21 or 22 by orientation) instead of a text note. The
android:name simple class ("ShareAsVideoAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsVideoAlias"
android:exported="true"
android:label="@string/share_target_as_video"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_video">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
</activity-alias>
<!-- "New Highlight" share target: a browser (or reader) shares a selected passage of
text and it opens the NIP-84 highlight composer. Text-only — a highlight is a text
passage, so no image/video filters here. The android:name simple class
@@ -4145,6 +4145,7 @@ class Account(
alt: String?,
contentWarningReason: String?,
originalHash: String? = null,
videoKind: VideoPostKind = VideoPostKind.AUTO,
) {
if (!isWriteable()) return
@@ -4188,7 +4189,10 @@ class Account(
thumbhash = headerInfo.thumbHash?.thumbhash,
)
if (headerInfo.dim.height > headerInfo.dim.width) {
// The composer forces the kind when it was opened from a feed that only reads one of
// them (Shorts, Longs) or from that feed's share target, so the post lands where the
// user asked for it. Everywhere else the orientation decides.
if (videoKind.isShort(headerInfo.dim)) {
VideoShortEvent.build(videoMeta, alt ?: "") {
contentWarningReason?.let { contentWarning(contentWarningReason) }
}
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 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.model
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
/**
* Which NIP-71 kind a video upload is published as. Only videos are affected — whether an upload
* is a picture (NIP-68 kind 20) or a video is decided by the file's mime type and can't be
* overridden.
*
* The composer picks this from the feed it was opened on, so a post always lands in the feed the
* user was standing in (or shared to): the Shorts feed reads kind 22, the Longs feed reads kind 21
* and the Video feed reads both.
*/
enum class VideoPostKind {
/** Derive from the video's dimensions: portrait -> kind 22, landscape -> kind 21. */
AUTO,
/** Always NIP-71 kind 22 (short-form video), regardless of orientation. */
SHORT,
/** Always NIP-71 kind 21 (normal video), regardless of orientation. */
NORMAL,
;
/** True when a video of [dim] should be published as a NIP-71 kind 22 short. */
fun isShort(dim: DimensionTag): Boolean =
when (this) {
SHORT -> true
NORMAL -> false
AUTO -> dim.height > dim.width
}
}
@@ -31,6 +31,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.VideoPostKind
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
@@ -73,15 +74,20 @@ open class NewMediaModel : ViewModel() {
// Strip location and sensitive metadata from files before upload
var stripMetadata by mutableStateOf(true)
// Which NIP-71 kind videos in this batch are published as. Set by the composer's host feed.
var videoKind: VideoPostKind = VideoPostKind.AUTO
open fun load(
account: Account,
uris: ImmutableList<SelectedMedia>,
videoKind: VideoPostKind = VideoPostKind.AUTO,
) {
this.caption = ""
this.account = account
this.multiOrchestrator = MultiOrchestrator(uris)
this.selectedServer = defaultServer()
this.stripMetadata = account.settings.stripLocationOnUpload
this.videoKind = videoKind
}
fun isImage(
@@ -180,6 +186,7 @@ open class NewMediaModel : ViewModel() {
alt = caption,
contentWarningReason = if (sensitiveContent) "" else null,
originalHash = it.uploadedHash,
videoKind = videoKind,
)
}
}
@@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.VideoPostKind
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
@@ -79,14 +80,15 @@ fun NewMediaView(
postViewModel: NewMediaModel,
accountViewModel: AccountViewModel,
nav: INav,
videoKind: VideoPostKind = VideoPostKind.AUTO,
) {
val account = accountViewModel.account
val context = LocalContext.current
val scrollState = rememberScrollState()
LaunchedEffect(uris) {
postViewModel.load(account, uris)
LaunchedEffect(uris, videoKind) {
postViewModel.load(account, uris, videoKind)
}
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
@@ -428,7 +428,7 @@ fun BuildNavigation(
) {
composableCapped<Route.Home> { HomeScreen(accountViewModel, nav) }
composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
composableCapped<Route.Video> { VideoScreen(accountViewModel, nav) }
composableArgs<Route.Video> { VideoScreen(accountViewModel, nav, it.attachment) }
composableArgs<Route.Discover> { DiscoverScreen(it.initialTab, accountViewModel, nav) }
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
@@ -439,7 +439,7 @@ fun BuildNavigation(
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileAppRecommendations> { ProfileAppRecommendationsScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEndArgs<Route.Pictures> { PicturesScreen(accountViewModel, nav, it.attachment) }
composableFromEnd<Route.Workouts> { WorkoutsScreen(accountViewModel, nav) }
composableFromEnd<Route.GitRepositories> { GitRepositoriesScreen(accountViewModel, nav) }
@@ -469,7 +469,7 @@ fun BuildNavigation(
}
composableFromBottomArgs<Route.NewCalendarCollection> { NewCalendarCollectionScreen(nav, accountViewModel, it.dTag) }
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.Shorts> { ShortsScreen(accountViewModel, nav, it.attachment) }
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
composableFromEndArgs<Route.BuzzDmList> { BuzzDmListScreen(it.relayUrl, accountViewModel, nav) }
@@ -988,21 +988,21 @@ private fun NavigateIfIntentRequested(
val activity = LocalContext.current.getActivity()
if (activity.intent.action == Intent.ACTION_SEND) {
val isShareAsDm = ShareIntentRouting.isShareAsDm(activity.intent.component?.className)
val isShareAsHighlight = ShareIntentRouting.isShareAsHighlight(activity.intent.component?.className)
val target = ShareIntentRouting.targetOf(activity.intent.component?.className)
// avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions
if (isShareAsHighlight) {
if (isBaseRoute<Route.NewHighlight>(nav.controller)) return
} else if (isShareAsDm) {
if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
} else {
if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
// Microsoft's swift key sends Gifs as new actions.
// The media targets land on a feed the user may well be standing on already, so they can't
// guard on the destination — they rely on the intent being consumed below instead.
when (target) {
ShareTarget.HIGHLIGHT -> if (isBaseRoute<Route.NewHighlight>(nav.controller)) return
ShareTarget.DIRECT_MESSAGE -> if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
ShareTarget.NEW_POST -> if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
ShareTarget.PICTURE, ShareTarget.SHORT_VIDEO, ShareTarget.VIDEO -> Unit
}
// saves the intent to avoid processing again
var message by remember {
val message by remember {
mutableStateOf(
activity.intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
it.ifBlank { null }
@@ -1010,26 +1010,29 @@ private fun NavigateIfIntentRequested(
)
}
var media by remember {
val media by remember {
mutableStateOf(
IntentCompat.getParcelableExtra(activity.intent, Intent.EXTRA_STREAM, Uri::class.java),
)
}
if (isShareAsHighlight) {
val parsed = message?.let { SharedHighlightParser.parse(it) }
nav.newStack(
Route.NewHighlight(
quote = parsed?.quote,
url = parsed?.url,
prefix = parsed?.prefix,
suffix = parsed?.suffix,
),
)
} else if (isShareAsDm) {
nav.newStack(Route.ShareToDM(message = message, attachment = media?.toString()))
} else {
nav.newStack(Route.NewShortNote(message = message, attachment = media.toString()))
when (target) {
ShareTarget.HIGHLIGHT -> {
val parsed = message?.let { SharedHighlightParser.parse(it) }
nav.newStack(
Route.NewHighlight(
quote = parsed?.quote,
url = parsed?.url,
prefix = parsed?.prefix,
suffix = parsed?.suffix,
),
)
}
ShareTarget.DIRECT_MESSAGE -> nav.newStack(Route.ShareToDM(message = message, attachment = media?.toString()))
ShareTarget.PICTURE -> nav.newStack(Route.Pictures(attachment = media?.toString()))
ShareTarget.SHORT_VIDEO -> nav.newStack(Route.Shorts(attachment = media?.toString()))
ShareTarget.VIDEO -> nav.newStack(Route.Video(attachment = media?.toString()))
ShareTarget.NEW_POST -> nav.newStack(Route.NewShortNote(message = message, attachment = media?.toString()))
}
// Consume the launch intent so a later recomposition can't re-fire
@@ -1097,37 +1100,43 @@ private fun NavigateIfIntentRequested(
val consumer =
Consumer<Intent> { intent ->
if (intent.action == Intent.ACTION_SEND) {
val isShareAsDm = ShareIntentRouting.isShareAsDm(intent.component?.className)
val isShareAsHighlight = ShareIntentRouting.isShareAsHighlight(intent.component?.className)
// avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions
if (isShareAsHighlight) {
if (!isBaseRoute<Route.NewHighlight>(nav.controller)) {
val parsed = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }?.let { SharedHighlightParser.parse(it) }
nav.newStack(
Route.NewHighlight(
quote = parsed?.quote,
url = parsed?.url,
prefix = parsed?.prefix,
suffix = parsed?.suffix,
),
)
}
} else if (isShareAsDm) {
if (!isBaseRoute<Route.ShareToDM>(nav.controller)) {
val message = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
val attachment =
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.toString()
nav.newStack(Route.ShareToDM(message = message, attachment = attachment))
}
} else if (!isBaseRoute<Route.NewShortNote>(nav.controller)) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
nav.newStack(Route.NewShortNote(message = it))
}
val target = ShareIntentRouting.targetOf(intent.component?.className)
val message = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
val attachment =
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.toString()
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let {
nav.newStack(Route.NewShortNote(attachment = it.toString()))
}
// avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions.
// The media targets land on a feed the user may well be standing on already, so
// they always navigate: the route carries the attachment, so the composer opens
// on the newly shared file even when the feed itself is already on screen.
when (target) {
ShareTarget.HIGHLIGHT ->
if (!isBaseRoute<Route.NewHighlight>(nav.controller)) {
val parsed = message?.let { SharedHighlightParser.parse(it) }
nav.newStack(
Route.NewHighlight(
quote = parsed?.quote,
url = parsed?.url,
prefix = parsed?.prefix,
suffix = parsed?.suffix,
),
)
}
ShareTarget.DIRECT_MESSAGE ->
if (!isBaseRoute<Route.ShareToDM>(nav.controller)) {
nav.newStack(Route.ShareToDM(message = message, attachment = attachment))
}
ShareTarget.PICTURE -> nav.newStack(Route.Pictures(attachment = attachment))
ShareTarget.SHORT_VIDEO -> nav.newStack(Route.Shorts(attachment = attachment))
ShareTarget.VIDEO -> nav.newStack(Route.Video(attachment = attachment))
ShareTarget.NEW_POST ->
if (!isBaseRoute<Route.NewShortNote>(nav.controller) && (message != null || attachment != null)) {
nav.newStack(Route.NewShortNote(message = message, attachment = attachment))
}
}
} else {
val uri = intent.data?.toString()
@@ -20,10 +20,31 @@
*/
package com.vitorpamplona.amethyst.ui.navigation
/** Which entry of Android's share sheet the user picked. */
enum class ShareTarget {
/** The default "New Post" target: a kind-1 note with the shared text/media. */
NEW_POST,
/** "Send as DM": a NIP-17 private message. */
DIRECT_MESSAGE,
/** "New Highlight": a NIP-84 highlight of the shared passage. */
HIGHLIGHT,
/** "New Picture": a NIP-68 picture post, straight into the picture feed. */
PICTURE,
/** "New Short": a NIP-71 kind 22 video, straight into the Shorts feed. */
SHORT_VIDEO,
/** "New Video": a NIP-71 video, straight into the Video feed. */
VIDEO,
}
/**
* Distinguishes the extra share targets ("Send as DM", "New Highlight") from the default
* "New Post" share target. Every SEND intent-filter resolves to MainActivity; they are told
* apart by the component class name of the launching intent (the activity-alias name).
* Tells the share targets apart. Every SEND intent-filter resolves to MainActivity; which target
* the user picked is only visible in the component class name of the launching intent (the
* `<activity-alias>` name), so each alias below maps to the composer it opens.
*/
object ShareIntentRouting {
/**
@@ -41,7 +62,35 @@ object ShareIntentRouting {
*/
const val SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME = "ShareAsHighlightAlias"
fun isShareAsDm(componentClassName: String?): Boolean = componentClassName?.endsWith(".$SHARE_AS_DM_ALIAS_SIMPLE_NAME") == true
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
const val SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME = "ShareAsPictureAlias"
fun isShareAsHighlight(componentClassName: String?): Boolean = componentClassName?.endsWith(".$SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME") == true
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
const val SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME = "ShareAsShortVideoAlias"
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
const val SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME = "ShareAsVideoAlias"
private val TARGET_BY_ALIAS =
mapOf(
SHARE_AS_DM_ALIAS_SIMPLE_NAME to ShareTarget.DIRECT_MESSAGE,
SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME to ShareTarget.HIGHLIGHT,
SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME to ShareTarget.PICTURE,
SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME to ShareTarget.SHORT_VIDEO,
SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME to ShareTarget.VIDEO,
)
/**
* Resolves the launching component to its target. Flavors can change the resolved package
* prefix, so the match is on the simple name; anything that isn't one of our aliases (chiefly
* MainActivity itself) is the default New Post target.
*/
fun targetOf(componentClassName: String?): ShareTarget {
if (componentClassName == null) return ShareTarget.NEW_POST
return TARGET_BY_ALIAS.entries
.firstOrNull { componentClassName.endsWith(".${it.key}") }
?.value
?: ShareTarget.NEW_POST
}
}
@@ -112,7 +112,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
id = NavBarItem.VIDEO,
labelRes = R.string.route_video,
icon = MaterialSymbols.Subscriptions,
resolveRoute = { Route.Video },
resolveRoute = { Route.Video() },
),
NavBarItem.DISCOVER to
NavBarItemDef(
@@ -231,7 +231,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
id = NavBarItem.PICTURES,
labelRes = R.string.pictures,
icon = MaterialSymbols.Photo,
resolveRoute = { Route.Pictures },
resolveRoute = { Route.Pictures() },
),
NavBarItem.WORKOUTS to
NavBarItemDef(
@@ -308,7 +308,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
id = NavBarItem.SHORTS,
labelRes = R.string.shorts,
icon = MaterialSymbols.PlayCircle,
resolveRoute = { Route.Shorts },
resolveRoute = { Route.Shorts() },
),
NavBarItem.MUSIC_TRACKS to
NavBarItemDef(
@@ -35,7 +35,13 @@ sealed class Route {
@Serializable object Message : Route()
@Serializable object Video : Route()
/**
* The mixed media feed. [attachment] is set by the "New Video" share target and pre-loads the
* media composer with the shared content URI.
*/
@Serializable data class Video(
val attachment: String? = null,
) : Route()
@Serializable data class Discover(
val initialTab: DiscoverTab? = null,
@@ -81,7 +87,13 @@ sealed class Route {
)
}
@Serializable object Pictures : Route()
/**
* The picture feed. [attachment] is set by the "New Picture" share target and pre-loads the
* media composer with the shared content URI.
*/
@Serializable data class Pictures(
val attachment: String? = null,
) : Route()
@Serializable object Workouts : Route()
@@ -175,7 +187,13 @@ sealed class Route {
@Serializable object Products : Route()
@Serializable object Shorts : Route()
/**
* The short-video feed. [attachment] is set by the "New Short" share target and pre-loads the
* media composer with the shared content URI.
*/
@Serializable data class Shorts(
val attachment: String? = null,
) : Route()
@Serializable object PublicChats : Route()
@@ -45,6 +45,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.VideoPostKind
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
@@ -63,6 +64,10 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Everything posted from here is a NIP-71 kind 21 video, whether it was recorded, picked or shared,
* so it always lands in the Longs feed the composer was opened from — portrait footage included.
*/
@Composable
fun NewLongVideoButton(
accountViewModel: AccountViewModel,
@@ -106,6 +111,7 @@ fun NewLongVideoButton(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
videoKind = VideoPostKind.NORMAL,
)
}
@@ -34,12 +34,14 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
@@ -50,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -63,11 +66,17 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* @param sharedAttachment content URI of a picture handed over by the "New Picture" share target.
* When present the composer opens on it right away, so the share lands as a NIP-68 picture post
* in this feed instead of a text note.
*/
@Composable
fun NewPictureButton(
accountViewModel: AccountViewModel,
nav: INav,
navScrollToTop: () -> Unit,
sharedAttachment: String? = null,
) {
var isOpen by remember { mutableStateOf(false) }
var wantsToPostFromCamera by remember { mutableStateOf(false) }
@@ -83,6 +92,11 @@ fun NewPictureButton(
}
}
val context = LocalContext.current
LaunchedEffect(sharedAttachment) {
resolveSharedMedia(context, sharedAttachment)?.let { pickedURIs = persistentListOf(it) }
}
if (wantsToPostFromCamera) {
TakePicture { uri ->
wantsToPostFromCamera = false
@@ -42,11 +42,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.Picture
fun PicturesScreen(
accountViewModel: AccountViewModel,
nav: INav,
attachment: String? = null,
) {
PicturesScreen(
picturesFeedContentState = accountViewModel.feedStates.picturesFeed,
accountViewModel = accountViewModel,
nav = nav,
attachment = attachment,
)
}
@@ -55,6 +57,7 @@ fun PicturesScreen(
picturesFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
attachment: String? = null,
) {
WatchLifecycleAndUpdateModel(picturesFeedContentState)
WatchAccountForPicturesScreen(picturesFeedContentState = picturesFeedContentState, accountViewModel = accountViewModel)
@@ -66,8 +69,8 @@ fun PicturesScreen(
PicturesTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Pictures, nav, accountViewModel) { route ->
if (route == Route.Pictures) {
AppBottomBar(Route.Pictures(), nav, accountViewModel) { route ->
if (route is Route.Pictures) {
picturesFeedContentState.sendToTop()
} else {
nav.navBottomBar(route)
@@ -76,7 +79,7 @@ fun PicturesScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop)
NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop, attachment)
}
},
accountViewModel = accountViewModel,
@@ -34,22 +34,26 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.VideoPostKind
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideo
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -63,11 +67,19 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* @param sharedAttachment content URI of a video handed over by the "New Short" share target. When
* present the composer opens on it right away.
*
* Everything posted from here is a NIP-71 kind 22 short, whether it was recorded, picked or shared,
* so it always lands in the Shorts feed the composer was opened from — landscape footage included.
*/
@Composable
fun NewShortVideoButton(
accountViewModel: AccountViewModel,
nav: INav,
navScrollToTop: () -> Unit,
sharedAttachment: String? = null,
) {
var isOpen by remember { mutableStateOf(false) }
var wantsToRecordVideo by remember { mutableStateOf(false) }
@@ -83,6 +95,11 @@ fun NewShortVideoButton(
}
}
val context = LocalContext.current
LaunchedEffect(sharedAttachment) {
resolveSharedMedia(context, sharedAttachment)?.let { pickedURIs = persistentListOf(it) }
}
if (wantsToRecordVideo) {
TakeVideo { uri ->
wantsToRecordVideo = false
@@ -106,6 +123,7 @@ fun NewShortVideoButton(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
videoKind = VideoPostKind.SHORT,
)
}
@@ -42,11 +42,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFil
fun ShortsScreen(
accountViewModel: AccountViewModel,
nav: INav,
attachment: String? = null,
) {
ShortsScreen(
shortsFeedContentState = accountViewModel.feedStates.shortsFeed,
accountViewModel = accountViewModel,
nav = nav,
attachment = attachment,
)
}
@@ -55,6 +57,7 @@ fun ShortsScreen(
shortsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
attachment: String? = null,
) {
WatchLifecycleAndUpdateModel(shortsFeedContentState)
WatchAccountForShortsScreen(videoFeedState = shortsFeedContentState, accountViewModel = accountViewModel)
@@ -66,8 +69,8 @@ fun ShortsScreen(
ShortsTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Shorts, nav, accountViewModel) { route ->
if (route == Route.Shorts) {
AppBottomBar(Route.Shorts(), nav, accountViewModel) { route ->
if (route is Route.Shorts) {
shortsFeedContentState.sendToTop()
} else {
nav.navBottomBar(route)
@@ -76,7 +79,7 @@ fun ShortsScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewShortVideoButton(accountViewModel, nav, shortsFeedContentState::sendToTop)
NewShortVideoButton(accountViewModel, nav, shortsFeedContentState::sendToTop, attachment)
}
},
accountViewModel = accountViewModel,
@@ -34,12 +34,14 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
@@ -51,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideo
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -64,11 +67,20 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The composer for the Video feed, which renders every media kind. Videos keep the automatic
* kind choice (portrait -> NIP-71 kind 22, landscape -> kind 21); either way they show up here.
*
* @param sharedAttachment content URI of a video handed over by the "New Video" share target. When
* present the composer opens on it right away, so the share lands as a NIP-71 video event in this
* feed instead of a text note.
*/
@Composable
fun NewImageButton(
accountViewModel: AccountViewModel,
nav: INav,
navScrollToTop: () -> Unit,
sharedAttachment: String? = null,
) {
var isOpen by remember { mutableStateOf(false) }
@@ -90,6 +102,11 @@ fun NewImageButton(
}
}
val context = LocalContext.current
LaunchedEffect(sharedAttachment) {
resolveSharedMedia(context, sharedAttachment)?.let { pickedURIs = persistentListOf(it) }
}
if (wantsToPostFromCamera) {
TakePicture { uri ->
wantsToPostFromCamera = false
@@ -59,11 +59,13 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
fun VideoScreen(
accountViewModel: AccountViewModel,
nav: INav,
attachment: String? = null,
) {
VideoScreen(
accountViewModel.feedStates.videoFeed,
accountViewModel,
nav,
attachment,
)
}
@@ -72,6 +74,7 @@ fun VideoScreen(
videoFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
attachment: String? = null,
) {
WatchLifecycleAndUpdateModel(videoFeedContentState)
WatchAccountForVideoScreen(videoFeedContentState = videoFeedContentState, accountViewModel = accountViewModel)
@@ -83,8 +86,8 @@ fun VideoScreen(
StoriesTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Video, nav, accountViewModel) { route ->
if (route == Route.Video) {
AppBottomBar(Route.Video(), nav, accountViewModel) { route ->
if (route is Route.Video) {
videoFeedContentState.sendToTop()
} else {
nav.navBottomBar(route)
@@ -93,7 +96,7 @@ fun VideoScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewImageButton(accountViewModel, nav, videoFeedContentState::sendToTop)
NewImageButton(accountViewModel, nav, videoFeedContentState::sendToTop, attachment)
}
},
accountViewModel = accountViewModel,
+3
View File
@@ -2772,6 +2772,9 @@
<string name="share_to_dm_title">Send to…</string>
<string name="share_to_dm_start_new">New message</string>
<string name="share_target_as_highlight">New Highlight</string>
<string name="share_target_as_picture">New Picture</string>
<string name="share_target_as_short_video">New Short</string>
<string name="share_target_as_video">New Video</string>
<string name="new_highlight_title">New Highlight</string>
<string name="new_highlight_passage_label">Highlighted text</string>
<string name="new_highlight_passage_placeholder">What stood out to you?</string>
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 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.model
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class VideoPostKindTest {
private val portrait = DimensionTag(1080, 1920)
private val landscape = DimensionTag(1920, 1080)
private val square = DimensionTag(1080, 1080)
@Test
fun autoFollowsTheOrientation() {
assertTrue(VideoPostKind.AUTO.isShort(portrait))
assertFalse(VideoPostKind.AUTO.isShort(landscape))
// A square video is not taller than it is wide, so it stays a normal video.
assertFalse(VideoPostKind.AUTO.isShort(square))
}
@Test
fun shortStaysShortEvenWhenTheFootageIsLandscape() {
// The Shorts feed only reads kind 22. A landscape video shared to the "New Short" target
// (or recorded from the Shorts composer) has to be a short or it never shows up there.
assertTrue(VideoPostKind.SHORT.isShort(landscape))
assertTrue(VideoPostKind.SHORT.isShort(portrait))
assertTrue(VideoPostKind.SHORT.isShort(square))
}
@Test
fun normalStaysNormalEvenWhenTheFootageIsPortrait() {
assertFalse(VideoPostKind.NORMAL.isShort(portrait))
assertFalse(VideoPostKind.NORMAL.isShort(landscape))
assertFalse(VideoPostKind.NORMAL.isShort(square))
}
}
@@ -21,34 +21,52 @@
package com.vitorpamplona.amethyst.navigation
import com.vitorpamplona.amethyst.ui.navigation.ShareIntentRouting
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import com.vitorpamplona.amethyst.ui.navigation.ShareTarget
import org.junit.Assert.assertEquals
import org.junit.Test
class ShareIntentRoutingTest {
@Test
fun detectsAliasByExactClassName() {
assertTrue(ShareIntentRouting.isShareAsDm("com.vitorpamplona.amethyst.ui.ShareAsDMAlias"))
assertEquals(ShareTarget.DIRECT_MESSAGE, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsDMAlias"))
}
@Test
fun detectsAliasRegardlessOfPackagePrefix() {
// Flavors can change the resolved package prefix; match on the simple name.
assertTrue(ShareIntentRouting.isShareAsDm("com.example.fork.ui.ShareAsDMAlias"))
assertEquals(ShareTarget.DIRECT_MESSAGE, ShareIntentRouting.targetOf("com.example.fork.ui.ShareAsDMAlias"))
}
@Test
fun rejectsMainActivity() {
assertFalse(ShareIntentRouting.isShareAsDm("com.vitorpamplona.amethyst.ui.MainActivity"))
fun detectsEveryAlias() {
assertEquals(ShareTarget.HIGHLIGHT, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsHighlightAlias"))
assertEquals(ShareTarget.PICTURE, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsPictureAlias"))
assertEquals(ShareTarget.SHORT_VIDEO, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsShortVideoAlias"))
assertEquals(ShareTarget.VIDEO, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsVideoAlias"))
}
@Test
fun rejectsNull() {
assertFalse(ShareIntentRouting.isShareAsDm(null))
fun mainActivityIsTheDefaultNewPostTarget() {
assertEquals(ShareTarget.NEW_POST, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.MainActivity"))
}
@Test
fun nullIsTheDefaultNewPostTarget() {
assertEquals(ShareTarget.NEW_POST, ShareIntentRouting.targetOf(null))
}
@Test
fun rejectsSuffixThatIsNotASimpleName() {
assertFalse(ShareIntentRouting.isShareAsDm("com.evil.XShareAsDMAlias"))
assertEquals(ShareTarget.NEW_POST, ShareIntentRouting.targetOf("com.evil.XShareAsDMAlias"))
assertEquals(ShareTarget.NEW_POST, ShareIntentRouting.targetOf("com.evil.XShareAsVideoAlias"))
}
/**
* "ShareAsShortVideoAlias" also ends with "VideoAlias", so a sloppier match would route every
* short to the plain video composer (kind 21 instead of 22 — the wrong feed).
*/
@Test
fun shortVideoAliasDoesNotCollideWithTheVideoAlias() {
assertEquals(ShareTarget.SHORT_VIDEO, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsShortVideoAlias"))
}
}