Merge pull request #3122 from davotoula/feat/share-as-dm

Share content directly to a DM ("Send as DM" share target)
This commit is contained in:
Vitor Pamplona
2026-06-02 08:00:35 -04:00
committed by GitHub
16 changed files with 537 additions and 9 deletions
+28
View File
@@ -218,6 +218,34 @@
</intent-filter>
</activity>
<!-- "Send as DM" share target. The android:name simple class ("ShareAsDMAlias")
is matched at runtime by ShareIntentRouting.SHARE_AS_DM_ALIAS_SIMPLE_NAME;
keep the two in sync when renaming. -->
<activity-alias
android:name=".ui.ShareAsDMAlias"
android:exported="true"
android:label="@string/share_target_as_dm"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_dm">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_dm">
<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_dm">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
</activity-alias>
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"
@@ -0,0 +1,42 @@
/*
* 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.ui.actions.uploads
import android.content.Context
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Resolves a shared content-URI string (from an Android SEND intent) into a
* [SelectedMedia] by reading its MIME type off the main thread. Returns null for
* a null/blank URI. Shared by the share-to-compose pre-fill paths (DM chatroom,
* group DM, …) so the URI→MIME→SelectedMedia logic lives in one place.
*/
suspend fun resolveSharedMedia(
context: Context,
uriString: String?,
): SelectedMedia? =
uriString?.ifBlank { null }?.toUri()?.let { uri ->
withContext(Dispatchers.IO) {
SelectedMedia(uri, context.contentResolver.getType(uri))
}
}
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28P
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
@@ -415,7 +416,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.GitRepository> { GitRepositoryScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.FollowPack> { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.attachment, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
composableFromEnd<Route.MarmotGroupList> { MarmotGroupListScreen(accountViewModel, nav) }
@@ -461,6 +462,7 @@ fun BuildNavigation(
composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) }
composableFromBottomArgs<Route.NewEphemeralChat> { NewEphemeralChatScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.NewGroupDM> { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
composableFromBottomArgs<Route.ShareToDM> { ShareToDMScreen(it.message, it.attachment, accountViewModel, nav) }
composableArgs<Route.EventRedirect> { LoadRedirectScreen(it.id, accountViewModel, nav) }
@@ -593,9 +595,15 @@ private fun NavigateIfIntentRequested(
val activity = LocalContext.current.getActivity()
if (activity.intent.action == Intent.ACTION_SEND) {
// avoids restarting the new Post screen when the intent is for the screen.
val isShareAsDm = ShareIntentRouting.isShareAsDm(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 (isBaseRoute<Route.NewShortNote>(nav.controller)) return
if (isShareAsDm) {
if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
} else {
if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
}
// saves the intent to avoid processing again
var message by remember {
@@ -612,7 +620,19 @@ private fun NavigateIfIntentRequested(
)
}
nav.newStack(Route.NewShortNote(message = message, attachment = media.toString()))
if (isShareAsDm) {
nav.newStack(Route.ShareToDM(message = message, attachment = media?.toString()))
} else {
nav.newStack(Route.NewShortNote(message = message, attachment = media.toString()))
}
// Consume the launch intent so a later recomposition can't re-fire
// newStack for the same share (the isBaseRoute guard is a non-reactive
// snapshot and stops guarding once we navigate past the destination,
// e.g. into a chat via the one-shot picker). Clearing the action also
// lets the else-branch register the onNewIntent listener for the rest
// of this session.
activity.intent.action = null
} else {
var newAccount by remember { mutableStateOf<String?>(null) }
@@ -671,9 +691,17 @@ private fun NavigateIfIntentRequested(
val consumer =
Consumer<Intent> { intent ->
if (intent.action == Intent.ACTION_SEND) {
// avoids restarting the new Post screen when the intent is for the screen.
val isShareAsDm = ShareIntentRouting.isShareAsDm(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 (!isBaseRoute<Route.NewShortNote>(nav.controller)) {
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))
}
@@ -0,0 +1,38 @@
/*
* 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.ui.navigation
/**
* Distinguishes the "Send as DM" share target from the default "New Post" share
* target. Both intent-filters resolve to MainActivity; they are told apart by the
* component class name of the launching intent (the activity-alias name).
*/
object ShareIntentRouting {
/**
* Simple class name of the `<activity-alias>` declared in AndroidManifest.xml
* (android:name=".ui.ShareAsDMAlias"). MUST stay in sync with the manifest —
* renaming the alias there without updating this constant silently routes
* "Send as DM" shares to the New Post composer (no build error).
*/
const val SHARE_AS_DM_ALIAS_SIMPLE_NAME = "ShareAsDMAlias"
fun isShareAsDm(componentClassName: String?): Boolean = componentClassName?.endsWith(".$SHARE_AS_DM_ALIAS_SIMPLE_NAME") == true
}
@@ -234,7 +234,7 @@ fun routeToMessage(
): Route {
account.chatroomList.getOrCreatePrivateChatroom(room)
return Route.Room(room, draftMessage, replyId, draftId, expiresDays)
return Route.Room(room, message = draftMessage, replyId = replyId, draftId = draftId, expiresDays = expiresDays)
}
fun routeToMessage(
@@ -523,16 +523,23 @@ sealed class Route {
val attachment: String? = null,
) : Route()
@Serializable data class ShareToDM(
val message: String? = null,
val attachment: String? = null,
) : Route()
@Serializable data class Room(
val id: String,
val message: String? = null,
val attachment: String? = null,
val replyId: HexKey? = null,
val draftId: HexKey? = null,
val expiresDays: Int? = null,
) : Route() {
constructor(key: ChatroomKey, message: String? = null, replyId: HexKey? = null, draftId: HexKey? = null, expiresDays: Int? = null) : this(
constructor(key: ChatroomKey, message: String? = null, attachment: String? = null, replyId: HexKey? = null, draftId: HexKey? = null, expiresDays: Int? = null) : this(
id = key.users.joinToString(","),
message = message,
attachment = attachment,
replyId = replyId,
draftId = draftId,
expiresDays = expiresDays,
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
fun ChatroomScreen(
roomId: ChatroomKey,
draftMessage: String? = null,
attachmentUri: String? = null,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
expiresDays: Int? = null,
@@ -86,6 +87,7 @@ fun ChatroomScreen(
ChatroomView(
room = roomId,
draftMessage = draftMessage,
attachmentUri = attachmentUri,
replyToNote = replyToNote,
editFromDraft = editFromDraft,
expiresDays = expiresDays,
@@ -29,9 +29,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
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.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
@@ -46,12 +48,14 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
@Composable
fun ChatroomView(
room: ChatroomKey,
draftMessage: String?,
attachmentUri: String? = null,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
expiresDays: Int? = null,
@@ -112,6 +116,14 @@ fun ChatroomView(
newPostModel.onMessageChanged()
}
}
val context = LocalContext.current
if (attachmentUri != null) {
LaunchedEffect(key1 = attachmentUri) {
resolveSharedMedia(context, attachmentUri)?.let {
newPostModel.pickedMedia(persistentListOf(it))
}
}
}
ChatroomViewUI(
room = room,
@@ -114,9 +114,15 @@ fun PrivateMessageEditFieldRow(
if (channelScreenModel.message.text.isNotBlank()) {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
// Rotate the draft tag only AFTER the async save completes. Doing it
// synchronously here (before launchSigner runs) would make sendDraftSync
// persist under a freshly-rotated tag, duplicating the draft. See the
// matching order in NewGroupDMScreen.
channelScreenModel.cancel()
}
} else {
channelScreenModel.cancel()
}
channelScreenModel.cancel()
nav.popBack()
}
@@ -0,0 +1,54 @@
/*
* 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.ui.screen.loggedIn.chats.share
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
/**
* Recent private-DM conversations only (no public channels, ephemeral chats, or
* marmot groups). Backs the Share-to-DM picker. Read-only/transient — extends the
* non-additive [FeedFilter] base because the picker loads once and does not need
* live additive updates.
*/
class ShareDMRoomsFeedFilter(
val account: Account,
) : FeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex
override fun feed(): List<Note> {
val chatList = account.chatroomList
val followingKeySet = account.followingKeySet()
return chatList.rooms
.mapNotNull { key, chatroom ->
if ((chatroom.senderIntersects(followingKeySet) || chatList.hasSentMessagesTo(key)) &&
!account.isAllHidden(key.users)
) {
chatroom.newestMessage
} else {
null
}
}.sortedWith(DefaultFeedOrder)
}
}
@@ -0,0 +1,49 @@
/*
* 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.ui.screen.loggedIn.chats.share
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
/**
* Wraps an [INav] so that navigating to a chatroom ([Route.Room]) from the
* Share-to-DM picker carries the shared message and attachment into the composer.
* All other navigation behavior is delegated unchanged.
*/
@Stable
class ShareToDMNav(
private val delegate: INav,
private val message: String?,
private val attachment: String?,
) : INav by delegate {
override fun nav(route: Route) {
val rewritten = ShareToDMRouteRewriter.rewrite(route, message, attachment)
if (route is Route.Room) {
// One-shot: replace the picker in the back stack so backing out of the
// chat exits the share flow instead of returning to the picker, which
// would re-inject the shared text on re-tap and create duplicate drafts.
delegate.popUpTo(rewritten, Route.ShareToDM::class)
} else {
delegate.nav(rewritten)
}
}
}
@@ -0,0 +1,40 @@
/*
* 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.ui.screen.loggedIn.chats.share
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
/**
* Injects shared content into a chatroom navigation so that tapping a recent
* conversation in the Share-to-DM picker opens the composer pre-filled.
*/
object ShareToDMRouteRewriter {
fun rewrite(
route: Route,
message: String?,
attachment: String?,
): Route =
if (route is Route.Room) {
route.copy(message = message, attachment = attachment)
} else {
route
}
}
@@ -0,0 +1,108 @@
/*
* 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.ui.screen.loggedIn.chats.share
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.ChatroomListFeedView
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareToDMScreen(
message: String?,
attachment: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
// Deliberately a screen-scoped, transient FeedContentState (not wired into
// AccountFeedContentStates like dmKnown/dmNew). The share picker is a one-shot,
// short-lived screen, so it owns its feed via viewModelScope and relies on
// WatchLifecycleAndUpdateModel to load/refresh on entry and resume rather than
// on the always-on additive update loop. Account switch recreates it (the
// remember key), which is correct for a transient picker.
val feedContentState =
remember(accountViewModel) {
FeedContentState(
ShareDMRoomsFeedFilter(accountViewModel.account),
accountViewModel.viewModelScope,
LocalCache,
)
}
val shareNav =
remember(nav, message, attachment) {
ShareToDMNav(nav, message, attachment)
}
WatchLifecycleAndUpdateModel(feedContentState)
Scaffold(
topBar = {
ShorterTopAppBar(title = { Text(stringRes(R.string.share_to_dm_title)) })
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
Text(
text = stringRes(R.string.share_to_dm_start_new),
modifier =
Modifier
.fillMaxWidth()
.clickable(
role = Role.Button,
onClickLabel = stringRes(R.string.share_to_dm_start_new),
) { nav.popUpTo(Route.NewGroupDM(message = message, attachment = attachment), Route.ShareToDM::class) }
.padding(16.dp),
)
HorizontalDivider(thickness = DividerThickness)
ChatroomListFeedView(
feedContentState = feedContentState,
scrollStateKey = "ShareToDM",
accountViewModel = accountViewModel,
nav = shareNav,
)
}
}
}
+3
View File
@@ -1661,6 +1661,9 @@
<string name="copy_nprofile_to_clipboard">Copy nprofile to clipboard</string>
<string name="copy_npub_to_clipboard">Copy npub to clipboard</string>
<string name="share_or_save">Share or Save</string>
<string name="share_target_as_dm">Send as DM</string>
<string name="share_to_dm_title">Send to…</string>
<string name="share_to_dm_start_new">New message</string>
<string name="copy_url_to_clipboard">Copy URL to clipboard</string>
<string name="copy_the_note_id_to_the_clipboard">Copy Note ID to clipboard</string>
<string name="add_media_to_gallery">Add Media to Gallery</string>
@@ -0,0 +1,54 @@
/*
* 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 org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ShareIntentRoutingTest {
@Test
fun detectsAliasByExactClassName() {
assertTrue(ShareIntentRouting.isShareAsDm("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"))
}
@Test
fun rejectsMainActivity() {
assertFalse(ShareIntentRouting.isShareAsDm("com.vitorpamplona.amethyst.ui.MainActivity"))
}
@Test
fun rejectsNull() {
assertFalse(ShareIntentRouting.isShareAsDm(null))
}
@Test
fun rejectsSuffixThatIsNotASimpleName() {
assertFalse(ShareIntentRouting.isShareAsDm("com.evil.XShareAsDMAlias"))
}
}
@@ -0,0 +1,57 @@
/*
* 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.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMRouteRewriter
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
class ShareToDMRouteRewriterTest {
@Test
fun injectsMessageAndAttachmentIntoRoomRoute() {
val original = Route.Room(id = "pubkeyA,pubkeyB")
val result = ShareToDMRouteRewriter.rewrite(original, "hello", "content://media/1")
result as Route.Room
assertEquals("pubkeyA,pubkeyB", result.id)
assertEquals("hello", result.message)
assertEquals("content://media/1", result.attachment)
}
@Test
fun preservesExistingRoomFields() {
val original = Route.Room(id = "x", replyId = "reply1", expiresDays = 3)
val result = ShareToDMRouteRewriter.rewrite(original, "hi", null) as Route.Room
assertEquals("reply1", result.replyId)
assertEquals(3, result.expiresDays)
assertEquals("hi", result.message)
}
@Test
fun leavesNonRoomRoutesUnchanged() {
val original = Route.Home
val result = ShareToDMRouteRewriter.rewrite(original, "hi", "uri")
assertSame(original, result)
}
}