From fd1d50662fd21621ed933732928a9333b0beadb7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 18:25:16 -0400 Subject: [PATCH] android: share a note with a group 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 --- .../com/fabledsword/inkwell/ui/ShareSheet.kt | 58 ++++++++++------- .../fabledsword/inkwell/ui/ShareViewModel.kt | 49 +++++++++++--- android/app/src/main/res/values/strings.xml | 6 +- android/ffi/src/lib.rs | 19 +++--- android/ffi/src/models.rs | 65 ++++++++++++++++++- 5 files changed, 153 insertions(+), 44 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/ShareSheet.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/ShareSheet.kt index be0b751..9078493 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/ShareSheet.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/ShareSheet.kt @@ -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(null) } + var pick by remember(state.noteId) { mutableStateOf(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, ) diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/ShareViewModel.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/ShareViewModel.kt index 1298eac..d1da29a 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/ShareViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/ShareViewModel.kt @@ -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 = emptyList(), + /** The groups the admin made (#5177); a note shared with one reaches its members. */ + val groups: List = emptyList(), val shares: List = 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 + /** The people and groups it isn't shared with yet, people first. */ + val available: List 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) } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a2e77e8..d49dfdd 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -67,7 +67,7 @@ Sharing is between people on a server. Link this phone in Sync to share notes. Not shared with anyone yet. Shared with everyone on this server. - Add someone + Add someone or a group Share Stop sharing Can view @@ -77,6 +77,10 @@ Shared by %1$s · you can edit the text Shared From %1$s + + Group · %d person + Group · %d people + diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs index b7307fb..3bfec80 100644 --- a/android/ffi/src/lib.rs +++ b/android/ffi/src/lib.rs @@ -43,8 +43,9 @@ use inkwell_core::sync::blobs::BlobStore; use inkwell_core::sync::{client, compat, engine, push, sharing, state}; use models::{ - patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Member, Note, NoteDraft, - NoteEdit, NoteQuery, NoteShare, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus, + patch_from, BodyItem, BodyTag, ClientUpdate, Directory, Identity, Label, Note, NoteDraft, + NoteEdit, NoteQuery, NoteShare, ProbeResult, RevokeOutcome, ShareTarget, SyncOutcome, + SyncStatus, }; uniffi::setup_scaffolding!(); @@ -612,13 +613,13 @@ impl Inkwell { // The Share dialog asks the server directly (#5175). Unlinked, each answers // `NotLinked`, which the dialog turns into "sharing needs a server". - /// Everyone on the instance a note can be shared with. - pub async fn share_directory(&self) -> Result, CoreError> { + /// Everyone on the instance a note can be shared with, and every group. + pub async fn share_directory(&self) -> Result { self.credentials()?; - let members = sharing::directory(&self.db) + let directory = sharing::directory(&self.db) .await .map_err(CoreError::network)?; - Ok(members.into_iter().map(Member::from).collect()) + Ok(directory.into()) } /// Who this note is shared with. @@ -630,15 +631,15 @@ impl Inkwell { Ok(shares.into_iter().map(NoteShare::from).collect()) } - /// Share with one member at "view" or "edit", or change their permission. + /// Share with a person or a group at "view" or "edit", or change their permission. pub async fn share_note( &self, note_id: String, - user_id: String, + target: ShareTarget, permission: String, ) -> Result, CoreError> { self.credentials()?; - let shares = sharing::share(&self.db, ¬e_id, &user_id, &permission) + let shares = sharing::share(&self.db, ¬e_id, &target.into(), &permission) .await .map_err(CoreError::network)?; Ok(shares.into_iter().map(NoteShare::from).collect()) diff --git a/android/ffi/src/models.rs b/android/ffi/src/models.rs index e10f2be..8dd2dd2 100644 --- a/android/ffi/src/models.rs +++ b/android/ffi/src/models.rs @@ -636,11 +636,68 @@ impl From for Member { } } -/// One person a note is shared with, at "view" or "edit". +/// A group the admin made, which a note can be shared with (#5177). +#[derive(Debug, Clone, uniffi::Record)] +pub struct ShareGroup { + pub id: String, + pub name: String, + pub member_count: u32, +} + +impl From for ShareGroup { + fn from(value: core_client::ShareGroup) -> Self { + let core_client::ShareGroup { + id, + name, + member_count, + } = value; + ShareGroup { + id, + name, + member_count, + } + } +} + +/// Everyone a note can be shared with: the other people, and every group. +#[derive(Debug, Clone, uniffi::Record)] +pub struct Directory { + pub members: Vec, + pub groups: Vec, +} + +impl From for Directory { + fn from(value: core_client::Directory) -> Self { + let core_client::Directory { members, groups } = value; + Directory { + members: members.into_iter().map(Member::from).collect(), + groups: groups.into_iter().map(ShareGroup::from).collect(), + } + } +} + +/// Who a new share goes to: one person, or a group. +#[derive(Debug, Clone, uniffi::Enum)] +pub enum ShareTarget { + Member { id: String }, + Group { id: String }, +} + +impl From for core_client::ShareTarget { + fn from(value: ShareTarget) -> Self { + match value { + ShareTarget::Member { id } => core_client::ShareTarget::Member(id), + ShareTarget::Group { id } => core_client::ShareTarget::Group(id), + } + } +} + +/// One share of a note, at "view" or "edit": to a person or to a group, never both. #[derive(Debug, Clone, uniffi::Record)] pub struct NoteShare { pub id: String, - pub member: Member, + pub member: Option, + pub group: Option, pub permission: String, } @@ -650,12 +707,14 @@ impl From for NoteShare { let core_client::NoteShare { id, member, + group, permission, created_at: _, } = value; NoteShare { id, - member: member.into(), + member: member.map(Member::from), + group: group.map(ShareGroup::from), permission, } }