mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
Merge pull request #3825 from vitorpamplona/claude/intent-receivers-feed-sharing-rkwiv1
Add media share targets for Pictures, Shorts, and Video feeds
This commit is contained in:
@@ -295,6 +295,75 @@
|
||||
</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>
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_picture">
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<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>
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_short_video">
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<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>
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_video">
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<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,21 @@ 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,
|
||||
caption: String = "",
|
||||
) {
|
||||
this.caption = ""
|
||||
this.caption = caption
|
||||
this.account = account
|
||||
this.multiOrchestrator = MultiOrchestrator(uris)
|
||||
this.selectedServer = defaultServer()
|
||||
this.stripMetadata = account.settings.stripLocationOnUpload
|
||||
this.videoKind = videoKind
|
||||
}
|
||||
|
||||
fun isImage(
|
||||
@@ -180,6 +187,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,16 @@ fun NewMediaView(
|
||||
postViewModel: NewMediaModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
videoKind: VideoPostKind = VideoPostKind.AUTO,
|
||||
initialCaption: String = "",
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
val context = LocalContext.current
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(uris) {
|
||||
postViewModel.load(account, uris)
|
||||
LaunchedEffect(uris, videoKind, initialCaption) {
|
||||
postViewModel.load(account, uris, videoKind, initialCaption)
|
||||
}
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
+20
@@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -40,3 +43,20 @@ suspend fun resolveSharedMedia(
|
||||
SelectedMedia(uri, context.contentResolver.getType(uri))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch form for SEND_MULTIPLE shares: resolves every URI in one trip to the IO dispatcher
|
||||
* instead of one context switch per file. Blank entries are dropped, so an empty list in means
|
||||
* an empty list out and the composer stays closed.
|
||||
*/
|
||||
suspend fun resolveSharedMedia(
|
||||
context: Context,
|
||||
uriStrings: List<String>,
|
||||
): ImmutableList<SelectedMedia> {
|
||||
val uris = uriStrings.mapNotNull { it.ifBlank { null }?.toUri() }
|
||||
if (uris.isEmpty()) return persistentListOf()
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
uris.map { SelectedMedia(it, context.contentResolver.getType(it)) }.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.TabReselectCoordinato
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.MediaFeedRoute
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.isBaseRoute
|
||||
@@ -428,7 +429,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.attachments, it.message) }
|
||||
composableArgs<Route.Discover> { DiscoverScreen(it.initialTab, accountViewModel, nav) }
|
||||
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
|
||||
@@ -439,7 +440,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.attachments, it.message) }
|
||||
composableFromEnd<Route.Workouts> { WorkoutsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.GitRepositories> { GitRepositoriesScreen(accountViewModel, nav) }
|
||||
|
||||
@@ -469,7 +470,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.attachments, it.message) }
|
||||
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.BuzzDmList> { BuzzDmListScreen(it.relayUrl, accountViewModel, nav) }
|
||||
@@ -971,6 +972,42 @@ fun BuildNavigation(
|
||||
}
|
||||
}
|
||||
|
||||
/** True for both share flavors: a single file/text (SEND) and a multi-file selection (SEND_MULTIPLE). */
|
||||
private fun Intent.isShareAction(): Boolean = action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE
|
||||
|
||||
/** The text Android sent with the share — a caption, a URL, or the whole payload of a text-only share. */
|
||||
private fun Intent.sharedText(): String? = getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
|
||||
|
||||
/**
|
||||
* Every content URI the share carries, in the order the sending app listed them. SEND_MULTIPLE
|
||||
* puts them in a parcelable ArrayList; plain SEND has at most one. Only the media targets declare
|
||||
* SEND_MULTIPLE, so every other caller can just take the first.
|
||||
*/
|
||||
private fun Intent.sharedStreamUris(): List<String> =
|
||||
if (action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
IntentCompat
|
||||
.getParcelableArrayListExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
?.map { it.toString() }
|
||||
.orEmpty()
|
||||
} else {
|
||||
listOfNotNull(IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)?.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a media feed on a share. Sharing again while an earlier share is still on screen replaces
|
||||
* it instead of stacking a second copy of the feed — but only when the entry on top is itself a
|
||||
* share (it carries attachments). A feed the user opened from the bottom bar carries none, so it
|
||||
* stays put underneath and keeps its tab-root marker.
|
||||
*/
|
||||
private inline fun <reified T> Nav.navToSharedFeed(route: T) where T : Route, T : MediaFeedRoute {
|
||||
val current = getRouteWithArguments(T::class, controller)
|
||||
if (current is MediaFeedRoute && current.attachments.isNotEmpty()) {
|
||||
popUpTo(route, T::class)
|
||||
} else {
|
||||
newStack(route)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavigateIfIntentRequested(
|
||||
nav: Nav,
|
||||
@@ -987,49 +1024,43 @@ 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)
|
||||
if (activity.intent.isShareAction()) {
|
||||
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 {
|
||||
mutableStateOf(
|
||||
activity.intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
|
||||
it.ifBlank { null }
|
||||
},
|
||||
)
|
||||
}
|
||||
val message = remember { activity.intent.sharedText() }
|
||||
val attachments = remember { activity.intent.sharedStreamUris() }
|
||||
|
||||
var 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,
|
||||
),
|
||||
)
|
||||
}
|
||||
// The single-file composers take the first URI: only the media targets declare
|
||||
// SEND_MULTIPLE, so anything else can only be carrying one.
|
||||
ShareTarget.DIRECT_MESSAGE -> nav.newStack(Route.ShareToDM(message = message, attachment = attachments.firstOrNull()))
|
||||
ShareTarget.PICTURE -> nav.navToSharedFeed(Route.Pictures(attachments = attachments, message = message))
|
||||
ShareTarget.SHORT_VIDEO -> nav.navToSharedFeed(Route.Shorts(attachments = attachments, message = message))
|
||||
ShareTarget.VIDEO -> nav.navToSharedFeed(Route.Video(attachments = attachments, message = message))
|
||||
ShareTarget.NEW_POST -> nav.newStack(Route.NewShortNote(message = message, attachment = attachments.firstOrNull()))
|
||||
}
|
||||
|
||||
// Consume the launch intent so a later recomposition can't re-fire
|
||||
@@ -1096,38 +1127,44 @@ private fun NavigateIfIntentRequested(
|
||||
DisposableEffect(nav, activity) {
|
||||
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))
|
||||
}
|
||||
if (intent.isShareAction()) {
|
||||
val target = ShareIntentRouting.targetOf(intent.component?.className)
|
||||
val message = intent.sharedText()
|
||||
val attachments = intent.sharedStreamUris()
|
||||
val attachment = attachments.firstOrNull()
|
||||
|
||||
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.navToSharedFeed(Route.Pictures(attachments = attachments, message = message))
|
||||
ShareTarget.SHORT_VIDEO -> nav.navToSharedFeed(Route.Shorts(attachments = attachments, message = message))
|
||||
ShareTarget.VIDEO -> nav.navToSharedFeed(Route.Video(attachments = attachments, message = message))
|
||||
|
||||
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(
|
||||
|
||||
@@ -30,12 +30,32 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* A feed whose composer can be pre-loaded from an Android share. [attachments] holds the shared
|
||||
* content URIs and [message] the text sent alongside them; both are empty/null for every other way
|
||||
* of reaching the feed, which is how the share flow tells its own entries apart from an ordinary
|
||||
* bottom-bar visit.
|
||||
*/
|
||||
sealed interface MediaFeedRoute {
|
||||
val attachments: List<String>
|
||||
val message: String?
|
||||
}
|
||||
|
||||
sealed class Route {
|
||||
@Serializable object Home : Route()
|
||||
|
||||
@Serializable object Message : Route()
|
||||
|
||||
@Serializable object Video : Route()
|
||||
/**
|
||||
* The mixed media feed. [attachments] and [message] are set by the "New Video" share target: the content
|
||||
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
|
||||
* composer and its caption. Empty for every other way of reaching the feed.
|
||||
*/
|
||||
@Serializable data class Video(
|
||||
override val attachments: List<String> = emptyList(),
|
||||
override val message: String? = null,
|
||||
) : Route(),
|
||||
MediaFeedRoute
|
||||
|
||||
@Serializable data class Discover(
|
||||
val initialTab: DiscoverTab? = null,
|
||||
@@ -81,7 +101,16 @@ sealed class Route {
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable object Pictures : Route()
|
||||
/**
|
||||
* The picture feed. [attachments] and [message] are set by the "New Picture" share target: the content
|
||||
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
|
||||
* composer and its caption. Empty for every other way of reaching the feed.
|
||||
*/
|
||||
@Serializable data class Pictures(
|
||||
override val attachments: List<String> = emptyList(),
|
||||
override val message: String? = null,
|
||||
) : Route(),
|
||||
MediaFeedRoute
|
||||
|
||||
@Serializable object Workouts : Route()
|
||||
|
||||
@@ -175,7 +204,16 @@ sealed class Route {
|
||||
|
||||
@Serializable object Products : Route()
|
||||
|
||||
@Serializable object Shorts : Route()
|
||||
/**
|
||||
* The short-video feed. [attachments] and [message] are set by the "New Short" share target: the content
|
||||
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
|
||||
* composer and its caption. Empty for every other way of reaching the feed.
|
||||
*/
|
||||
@Serializable data class Shorts(
|
||||
override val attachments: List<String> = emptyList(),
|
||||
override val message: String? = null,
|
||||
) : Route(),
|
||||
MediaFeedRoute
|
||||
|
||||
@Serializable object PublicChats : Route()
|
||||
|
||||
|
||||
+8
@@ -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,12 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The Longs feed's composer. Every *video* posted from here is a NIP-71 kind-21 video — recorded or
|
||||
* picked alike — so portrait footage lands in the Longs feed too instead of being routed to kind 22
|
||||
* by its orientation. Images are unaffected: the gallery picker accepts them and they still post as
|
||||
* NIP-68 kind-20 pictures, which the Longs feed does not read.
|
||||
*/
|
||||
@Composable
|
||||
fun NewLongVideoButton(
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -106,6 +113,7 @@ fun NewLongVideoButton(
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
videoKind = VideoPostKind.NORMAL,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -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,23 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The picture feed's composer. Images post as NIP-68 kind-20 pictures and land in this feed; a
|
||||
* video picked from the gallery still posts as a NIP-71 video and shows up in the video feeds
|
||||
* instead, since the file's mime type — not the host feed — decides picture vs. video.
|
||||
*
|
||||
* @param sharedAttachments content URIs handed over by the "New Picture" share target. When
|
||||
* non-empty the composer opens on them right away, so the share lands as a picture post instead
|
||||
* of a text note with a link.
|
||||
* @param sharedCaption text Android sent alongside the files; pre-fills the caption field.
|
||||
*/
|
||||
@Composable
|
||||
fun NewPictureButton(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
navScrollToTop: () -> Unit,
|
||||
sharedAttachments: List<String> = emptyList(),
|
||||
sharedCaption: String? = null,
|
||||
) {
|
||||
var isOpen by remember { mutableStateOf(false) }
|
||||
var wantsToPostFromCamera by remember { mutableStateOf(false) }
|
||||
@@ -83,6 +98,11 @@ fun NewPictureButton(
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(sharedAttachments) {
|
||||
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
|
||||
}
|
||||
|
||||
if (wantsToPostFromCamera) {
|
||||
TakePicture { uri ->
|
||||
wantsToPostFromCamera = false
|
||||
@@ -106,6 +126,7 @@ fun NewPictureButton(
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
initialCaption = sharedCaption.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+9
-3
@@ -42,11 +42,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.Picture
|
||||
fun PicturesScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
attachments: List<String> = emptyList(),
|
||||
message: String? = null,
|
||||
) {
|
||||
PicturesScreen(
|
||||
picturesFeedContentState = accountViewModel.feedStates.picturesFeed,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
attachments = attachments,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,6 +59,8 @@ fun PicturesScreen(
|
||||
picturesFeedContentState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
attachments: List<String> = emptyList(),
|
||||
message: String? = null,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(picturesFeedContentState)
|
||||
WatchAccountForPicturesScreen(picturesFeedContentState = picturesFeedContentState, accountViewModel = accountViewModel)
|
||||
@@ -66,8 +72,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 +82,7 @@ fun PicturesScreen(
|
||||
},
|
||||
floatingButton = {
|
||||
FabBottomBarPadded(nav) {
|
||||
NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop)
|
||||
NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop, attachments, message)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
+23
@@ -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,23 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The Shorts feed's composer. Every *video* posted from here is a NIP-71 kind-22 short — recorded,
|
||||
* picked or shared alike — so landscape footage lands in the Shorts feed too instead of being
|
||||
* routed to kind 21 by its orientation. Images are unaffected: the gallery picker accepts them and
|
||||
* they still post as NIP-68 kind-20 pictures, which the Shorts feed does not read.
|
||||
*
|
||||
* @param sharedAttachments content URIs handed over by the "New Short" share target. When non-empty
|
||||
* the composer opens on them right away.
|
||||
* @param sharedCaption text Android sent alongside the files; pre-fills the caption field.
|
||||
*/
|
||||
@Composable
|
||||
fun NewShortVideoButton(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
navScrollToTop: () -> Unit,
|
||||
sharedAttachments: List<String> = emptyList(),
|
||||
sharedCaption: String? = null,
|
||||
) {
|
||||
var isOpen by remember { mutableStateOf(false) }
|
||||
var wantsToRecordVideo by remember { mutableStateOf(false) }
|
||||
@@ -83,6 +99,11 @@ fun NewShortVideoButton(
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(sharedAttachments) {
|
||||
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
|
||||
}
|
||||
|
||||
if (wantsToRecordVideo) {
|
||||
TakeVideo { uri ->
|
||||
wantsToRecordVideo = false
|
||||
@@ -106,6 +127,8 @@ fun NewShortVideoButton(
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
videoKind = VideoPostKind.SHORT,
|
||||
initialCaption = sharedCaption.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+9
-3
@@ -42,11 +42,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFil
|
||||
fun ShortsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
attachments: List<String> = emptyList(),
|
||||
message: String? = null,
|
||||
) {
|
||||
ShortsScreen(
|
||||
shortsFeedContentState = accountViewModel.feedStates.shortsFeed,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
attachments = attachments,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,6 +59,8 @@ fun ShortsScreen(
|
||||
shortsFeedContentState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
attachments: List<String> = emptyList(),
|
||||
message: String? = null,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(shortsFeedContentState)
|
||||
WatchAccountForShortsScreen(videoFeedState = shortsFeedContentState, accountViewModel = accountViewModel)
|
||||
@@ -66,8 +72,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 +82,7 @@ fun ShortsScreen(
|
||||
},
|
||||
floatingButton = {
|
||||
FabBottomBarPadded(nav) {
|
||||
NewShortVideoButton(accountViewModel, nav, shortsFeedContentState::sendToTop)
|
||||
NewShortVideoButton(accountViewModel, nav, shortsFeedContentState::sendToTop, attachments, message)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
+22
-1
@@ -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,23 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The Video feed's composer. That feed renders every media kind, so videos keep the automatic kind
|
||||
* choice (portrait -> NIP-71 kind 22, landscape -> kind 21) and pictures post as NIP-68 kind 20 —
|
||||
* all three show up here either way, which is why this is the one media composer that pins nothing.
|
||||
*
|
||||
* @param sharedAttachments content URIs handed over by the "New Video" share target. When non-empty
|
||||
* the composer opens on them right away, so the share lands as a NIP-71 video event instead of a
|
||||
* text note with a link.
|
||||
* @param sharedCaption text Android sent alongside the files; pre-fills the caption field.
|
||||
*/
|
||||
@Composable
|
||||
fun NewImageButton(
|
||||
fun NewVideoFeedButton(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
navScrollToTop: () -> Unit,
|
||||
sharedAttachments: List<String> = emptyList(),
|
||||
sharedCaption: String? = null,
|
||||
) {
|
||||
var isOpen by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -90,6 +105,11 @@ fun NewImageButton(
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(sharedAttachments) {
|
||||
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
|
||||
}
|
||||
|
||||
if (wantsToPostFromCamera) {
|
||||
TakePicture { uri ->
|
||||
wantsToPostFromCamera = false
|
||||
@@ -120,6 +140,7 @@ fun NewImageButton(
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
initialCaption = sharedCaption.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
+9
-3
@@ -59,11 +59,15 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
|
||||
fun VideoScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
attachments: List<String> = emptyList(),
|
||||
message: String? = null,
|
||||
) {
|
||||
VideoScreen(
|
||||
accountViewModel.feedStates.videoFeed,
|
||||
accountViewModel,
|
||||
nav,
|
||||
attachments,
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,6 +76,8 @@ fun VideoScreen(
|
||||
videoFeedContentState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
attachments: List<String> = emptyList(),
|
||||
message: String? = null,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(videoFeedContentState)
|
||||
WatchAccountForVideoScreen(videoFeedContentState = videoFeedContentState, accountViewModel = accountViewModel)
|
||||
@@ -83,8 +89,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 +99,7 @@ fun VideoScreen(
|
||||
},
|
||||
floatingButton = {
|
||||
FabBottomBarPadded(nav) {
|
||||
NewImageButton(accountViewModel, nav, videoFeedContentState::sendToTop)
|
||||
NewVideoFeedButton(accountViewModel, nav, videoFeedContentState::sendToTop, attachments, message)
|
||||
}
|
||||
},
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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.navigation
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.navigation.ShareIntentRouting
|
||||
import com.vitorpamplona.amethyst.ui.navigation.ShareTarget
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Guards the one link in the share-target chain the compiler cannot see: the `<activity-alias>`
|
||||
* names in AndroidManifest.xml against the `SHARE_AS_*_ALIAS_SIMPLE_NAME` constants that
|
||||
* [ShareIntentRouting] matches them by. Renaming an alias on one side only still builds and still
|
||||
* runs — the share just silently opens the wrong composer (a picture share falls through to the
|
||||
* default "New Post" target and becomes a text note).
|
||||
*
|
||||
* Checked in both directions, so neither a renamed constant nor a new alias without a constant
|
||||
* slips through.
|
||||
*/
|
||||
class ShareTargetManifestTest {
|
||||
private val aliasPattern = Regex("""<activity-alias[^>]*android:name="\.ui\.(\w+)"""", RegexOption.DOT_MATCHES_ALL)
|
||||
|
||||
private fun manifest(): String {
|
||||
// Gradle runs unit tests with the module directory as the working directory; fall back to
|
||||
// the repo root so the test also passes when run from an IDE configured that way.
|
||||
val candidates =
|
||||
listOf(
|
||||
File("src/main/AndroidManifest.xml"),
|
||||
File("amethyst/src/main/AndroidManifest.xml"),
|
||||
)
|
||||
val found = candidates.firstOrNull { it.isFile }
|
||||
assertTrue(
|
||||
"Could not locate AndroidManifest.xml from ${File(".").absolutePath}",
|
||||
found != null,
|
||||
)
|
||||
return found!!.readText()
|
||||
}
|
||||
|
||||
private fun declaredAliases(): Set<String> =
|
||||
aliasPattern
|
||||
.findAll(manifest())
|
||||
.map { it.groupValues[1] }
|
||||
.toSet()
|
||||
|
||||
@Test
|
||||
fun everyRoutedAliasIsDeclaredInTheManifest() {
|
||||
val declared = declaredAliases()
|
||||
|
||||
val expected =
|
||||
listOf(
|
||||
ShareIntentRouting.SHARE_AS_DM_ALIAS_SIMPLE_NAME,
|
||||
ShareIntentRouting.SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME,
|
||||
ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME,
|
||||
ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME,
|
||||
ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME,
|
||||
)
|
||||
|
||||
expected.forEach {
|
||||
assertTrue(
|
||||
"ShareIntentRouting routes \"$it\" but AndroidManifest.xml declares no such " +
|
||||
"<activity-alias android:name=\".ui.$it\">. Declared: $declared",
|
||||
it in declared,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyDeclaredAliasRoutesToItsOwnTarget() {
|
||||
declaredAliases().forEach { alias ->
|
||||
val target = ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.$alias")
|
||||
assertTrue(
|
||||
"AndroidManifest.xml declares <activity-alias android:name=\".ui.$alias\"> but " +
|
||||
"ShareIntentRouting has no constant for it, so shares to it fall through to " +
|
||||
"the default New Post composer.",
|
||||
target != ShareTarget.NEW_POST,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aliasesResolveToDistinctTargets() {
|
||||
val aliases = declaredAliases()
|
||||
val targets = aliases.map { ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.$it") }
|
||||
|
||||
assertEquals(
|
||||
"Two aliases resolve to the same ShareTarget — one of them is shadowed by a " +
|
||||
"suffix match. Aliases: $aliases, targets: $targets",
|
||||
targets.size,
|
||||
targets.toSet().size,
|
||||
)
|
||||
}
|
||||
|
||||
/** The media targets are the only ones that accept a multi-file selection. */
|
||||
@Test
|
||||
fun mediaAliasesAcceptSendMultiple() {
|
||||
val text = manifest()
|
||||
|
||||
listOf(
|
||||
ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME,
|
||||
ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME,
|
||||
ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME,
|
||||
).forEach { alias ->
|
||||
val block =
|
||||
text
|
||||
.substringAfter("android:name=\".ui.$alias\"")
|
||||
.substringBefore("</activity-alias>")
|
||||
|
||||
assertTrue(
|
||||
"The \".ui.$alias\" share target has no SEND_MULTIPLE intent-filter, so sharing " +
|
||||
"several files at once will not offer it.",
|
||||
block.contains("android.intent.action.SEND_MULTIPLE"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user