mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
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:
@@ -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()
|
||||
|
||||
+54
-5
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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()
|
||||
|
||||
|
||||
+6
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+14
@@ -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
|
||||
|
||||
+6
-3
@@ -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,
|
||||
|
||||
+18
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -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,
|
||||
|
||||
+17
@@ -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
|
||||
|
||||
+6
-3
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+27
-9
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user