android: share a note with a group
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m22s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m39s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m37s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m26s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m16s

The Share sheet lists groups after people, shows a group share as its name and
how many are in it, and shares through the FFI's ShareTarget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 18:25:16 -04:00
co-authored by Claude Opus 5.5
parent 442cca4398
commit fd1d50662f
5 changed files with 153 additions and 44 deletions
@@ -29,12 +29,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Member
import com.fabledsword.inkwell.core.Note
import com.fabledsword.inkwell.core.NoteShare
import com.fabledsword.inkwell.core.ShareGroup
import com.fabledsword.inkwell.core.ShareTarget
/** "Shared by Robin · view only" over someone else's note; nothing over our own. */
@Composable
@@ -59,16 +62,16 @@ private const val VIEW = "view"
private const val EDIT = "edit"
/**
* The Share sheet: who the note is shared with, and adding someone.
* The Share sheet: who the note is shared with, and adding someone or a group.
*
* A peer of the web's `ShareDialog.vue`. Each person's permission is a pair of
* chips rather than a menu, because there are exactly two and both fit.
* A peer of the web's `ShareDialog.vue`. Each share's permission is a pair of chips
* rather than a menu, because there are exactly two and both fit.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareSheet(
state: ShareState,
onShare: (userId: String, permission: String) -> Unit,
onShare: (target: ShareTarget, permission: String) -> Unit,
onUnshare: (shareId: String) -> Unit,
onDismiss: () -> Unit,
) {
@@ -96,7 +99,7 @@ fun ShareSheet(
@Composable
private fun ShareBody(
state: ShareState,
onShare: (userId: String, permission: String) -> Unit,
onShare: (target: ShareTarget, permission: String) -> Unit,
onUnshare: (shareId: String) -> Unit,
) {
if (state.shares.isEmpty()) {
@@ -106,7 +109,7 @@ private fun ShareBody(
ShareRow(
share = share,
busy = state.busy,
onPermission = { onShare(share.member.id, it) },
onPermission = { onShare(share.target, it) },
onRemove = { onUnshare(share.id) },
)
}
@@ -127,7 +130,7 @@ private fun ShareRow(
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
MemberName(share.member, Modifier.weight(1f))
TargetName(share.member, share.group, Modifier.weight(1f))
IconButton(onClick = onRemove, enabled = !busy) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.share_remove))
}
@@ -139,9 +142,9 @@ private fun ShareRow(
@Composable
private fun AddPerson(
state: ShareState,
onShare: (userId: String, permission: String) -> Unit,
onShare: (target: ShareTarget, permission: String) -> Unit,
) {
var pick by remember(state.noteId) { mutableStateOf<String?>(null) }
var pick by remember(state.noteId) { mutableStateOf<ShareCandidate?>(null) }
var permission by remember(state.noteId) { mutableStateOf(VIEW) }
Text(
@@ -152,13 +155,13 @@ private fun AddPerson(
// Capped, like the tag picker: a sheet that grows past the screen makes its own
// scroll fight the sheet's drag.
LazyColumn(modifier = Modifier.heightIn(max = MEMBER_LIST_MAX_HEIGHT)) {
items(items = state.available, key = { it.id }) { member ->
items(items = state.available, key = { it.key }) { candidate ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { pick = member.id },
modifier = Modifier.fillMaxWidth().clickable { pick = candidate },
) {
RadioButton(selected = pick == member.id, onClick = { pick = member.id })
MemberName(member, Modifier.weight(1f))
RadioButton(selected = pick?.key == candidate.key, onClick = { pick = candidate })
TargetName(candidate.member, candidate.group, Modifier.weight(1f))
}
}
}
@@ -168,7 +171,7 @@ private fun AddPerson(
TextButton(
enabled = pick != null && !state.busy,
onClick = {
pick?.let { onShare(it, permission) }
pick?.let { onShare(it.target, permission) }
pick = null
},
) { Text(stringResource(R.string.share_action)) }
@@ -197,19 +200,30 @@ private fun PermissionChips(
}
}
/** A person (name over email) or a group (name over how many are in it). */
@Composable
private fun MemberName(
member: Member,
private fun TargetName(
member: Member?,
group: ShareGroup?,
modifier: Modifier = Modifier,
) {
val title = group?.name ?: member?.let { it.displayName.ifBlank { it.email } }.orEmpty()
val detail =
when {
group != null ->
pluralStringResource(
R.plurals.share_group_members,
group.memberCount.toInt(),
group.memberCount.toInt(),
)
member != null && member.displayName.isNotBlank() -> member.email
else -> null
}
Column(modifier = modifier) {
Text(
text = member.displayName.ifBlank { member.email },
style = MaterialTheme.typography.bodyLarge,
)
if (member.displayName.isNotBlank()) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
detail?.let {
Text(
text = member.email,
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -10,6 +10,8 @@ import com.fabledsword.inkwell.core.CoreException
import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.Member
import com.fabledsword.inkwell.core.NoteShare
import com.fabledsword.inkwell.core.ShareGroup
import com.fabledsword.inkwell.core.ShareTarget
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -19,6 +21,8 @@ data class ShareState(
val noteId: String? = null,
val loading: Boolean = false,
val members: List<Member> = emptyList(),
/** The groups the admin made (#5177); a note shared with one reaches its members. */
val groups: List<ShareGroup> = emptyList(),
val shares: List<NoteShare> = emptyList(),
/** This device has no server, and sharing is between people on one. */
val needsServer: Boolean = false,
@@ -28,14 +32,36 @@ data class ShareState(
val busy: Boolean = false,
val error: String? = null,
) {
/** The people it isn't shared with yet. */
val available: List<Member>
/** The people and groups it isn't shared with yet, people first. */
val available: List<ShareCandidate>
get() {
val taken = shares.map { it.member.id }.toSet()
return members.filterNot { it.id in taken }
val people = shares.mapNotNull { it.member?.id }.toSet()
val grouped = shares.mapNotNull { it.group?.id }.toSet()
return members.filterNot { it.id in people }.map { ShareCandidate(member = it) } +
groups.filterNot { it.id in grouped }.map { ShareCandidate(group = it) }
}
}
/** Someone, or a group, the sheet offers to share with. Exactly one is set. */
data class ShareCandidate(
val member: Member? = null,
val group: ShareGroup? = null,
) {
val key: String
get() = group?.let { "g:${it.id}" } ?: "u:${member?.id}"
val target: ShareTarget
get() = targetOf(member, group)
}
/** Who an existing share goes to, to change its permission. */
val NoteShare.target: ShareTarget
get() = targetOf(member, group)
private fun targetOf(
member: Member?,
group: ShareGroup?,
): ShareTarget = group?.let { ShareTarget.Group(it.id) } ?: ShareTarget.Member(member?.id.orEmpty())
/**
* Sharing a note with other people on the linked server (#5175).
*
@@ -63,9 +89,14 @@ class ShareViewModel(
if (!withContext(Dispatchers.IO) { core.syncStatus().linked }) {
state.copy(loading = false, needsServer = true)
} else {
val members = core.shareDirectory()
val directory = core.shareDirectory()
val shares = core.noteShares(noteId)
state.copy(loading = false, members = members, shares = shares)
state.copy(
loading = false,
members = directory.members,
groups = directory.groups,
shares = shares,
)
}
} catch (e: CoreException) {
state.copy(loading = false, loadError = e.describeShareFailure())
@@ -77,11 +108,11 @@ class ShareViewModel(
state = ShareState()
}
/** Share with someone, or change what they may do. */
/** Share with someone or a group, or change what they may do. */
fun share(
userId: String,
target: ShareTarget,
permission: String,
) = write { noteId -> core.shareNote(noteId, userId, permission) }
) = write { noteId -> core.shareNote(noteId, target, permission) }
fun unshare(shareId: String) = write { noteId -> core.unshareNote(noteId, shareId) }
+5 -1
View File
@@ -67,7 +67,7 @@
<string name="share_needs_server">Sharing is between people on a server. Link this phone in Sync to share notes.</string>
<string name="share_none">Not shared with anyone yet.</string>
<string name="share_everyone">Shared with everyone on this server.</string>
<string name="share_add">Add someone</string>
<string name="share_add">Add someone or a group</string>
<string name="share_action">Share</string>
<string name="share_remove">Stop sharing</string>
<string name="share_can_view">Can view</string>
@@ -77,6 +77,10 @@
<string name="share_by_can_edit">Shared by %1$s · you can edit the text</string>
<string name="share_chip_shared">Shared</string>
<string name="share_chip_by">From %1$s</string>
<plurals name="share_group_members">
<item quantity="one">Group · %d person</item>
<item quantity="other">Group · %d people</item>
</plurals>
<!-- Attachments. A file is stored on the phone at once and uploaded on a later
sync, so nothing here talks about the network. -->