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.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.inkwell.R import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Member import com.fabledsword.inkwell.core.Member
import com.fabledsword.inkwell.core.Note import com.fabledsword.inkwell.core.Note
import com.fabledsword.inkwell.core.NoteShare 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. */ /** "Shared by Robin · view only" over someone else's note; nothing over our own. */
@Composable @Composable
@@ -59,16 +62,16 @@ private const val VIEW = "view"
private const val EDIT = "edit" 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 * A peer of the web's `ShareDialog.vue`. Each share's permission is a pair of chips
* chips rather than a menu, because there are exactly two and both fit. * rather than a menu, because there are exactly two and both fit.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ShareSheet( fun ShareSheet(
state: ShareState, state: ShareState,
onShare: (userId: String, permission: String) -> Unit, onShare: (target: ShareTarget, permission: String) -> Unit,
onUnshare: (shareId: String) -> Unit, onUnshare: (shareId: String) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
@@ -96,7 +99,7 @@ fun ShareSheet(
@Composable @Composable
private fun ShareBody( private fun ShareBody(
state: ShareState, state: ShareState,
onShare: (userId: String, permission: String) -> Unit, onShare: (target: ShareTarget, permission: String) -> Unit,
onUnshare: (shareId: String) -> Unit, onUnshare: (shareId: String) -> Unit,
) { ) {
if (state.shares.isEmpty()) { if (state.shares.isEmpty()) {
@@ -106,7 +109,7 @@ private fun ShareBody(
ShareRow( ShareRow(
share = share, share = share,
busy = state.busy, busy = state.busy,
onPermission = { onShare(share.member.id, it) }, onPermission = { onShare(share.target, it) },
onRemove = { onUnshare(share.id) }, onRemove = { onUnshare(share.id) },
) )
} }
@@ -127,7 +130,7 @@ private fun ShareRow(
) { ) {
Column { Column {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
MemberName(share.member, Modifier.weight(1f)) TargetName(share.member, share.group, Modifier.weight(1f))
IconButton(onClick = onRemove, enabled = !busy) { IconButton(onClick = onRemove, enabled = !busy) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.share_remove)) Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.share_remove))
} }
@@ -139,9 +142,9 @@ private fun ShareRow(
@Composable @Composable
private fun AddPerson( private fun AddPerson(
state: ShareState, 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) } var permission by remember(state.noteId) { mutableStateOf(VIEW) }
Text( Text(
@@ -152,13 +155,13 @@ private fun AddPerson(
// Capped, like the tag picker: a sheet that grows past the screen makes its own // Capped, like the tag picker: a sheet that grows past the screen makes its own
// scroll fight the sheet's drag. // scroll fight the sheet's drag.
LazyColumn(modifier = Modifier.heightIn(max = MEMBER_LIST_MAX_HEIGHT)) { 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( Row(
verticalAlignment = Alignment.CenterVertically, 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 }) RadioButton(selected = pick?.key == candidate.key, onClick = { pick = candidate })
MemberName(member, Modifier.weight(1f)) TargetName(candidate.member, candidate.group, Modifier.weight(1f))
} }
} }
} }
@@ -168,7 +171,7 @@ private fun AddPerson(
TextButton( TextButton(
enabled = pick != null && !state.busy, enabled = pick != null && !state.busy,
onClick = { onClick = {
pick?.let { onShare(it, permission) } pick?.let { onShare(it.target, permission) }
pick = null pick = null
}, },
) { Text(stringResource(R.string.share_action)) } ) { 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 @Composable
private fun MemberName( private fun TargetName(
member: Member, member: Member?,
group: ShareGroup?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier) { val title = group?.name ?: member?.let { it.displayName.ifBlank { it.email } }.orEmpty()
Text( val detail =
text = member.displayName.ifBlank { member.email }, when {
style = MaterialTheme.typography.bodyLarge, group != null ->
pluralStringResource(
R.plurals.share_group_members,
group.memberCount.toInt(),
group.memberCount.toInt(),
) )
if (member.displayName.isNotBlank()) { member != null && member.displayName.isNotBlank() -> member.email
else -> null
}
Column(modifier = modifier) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
detail?.let {
Text( Text(
text = member.email, text = it,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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.Inkwell
import com.fabledsword.inkwell.core.Member import com.fabledsword.inkwell.core.Member
import com.fabledsword.inkwell.core.NoteShare 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -19,6 +21,8 @@ data class ShareState(
val noteId: String? = null, val noteId: String? = null,
val loading: Boolean = false, val loading: Boolean = false,
val members: List<Member> = emptyList(), 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(), val shares: List<NoteShare> = emptyList(),
/** This device has no server, and sharing is between people on one. */ /** This device has no server, and sharing is between people on one. */
val needsServer: Boolean = false, val needsServer: Boolean = false,
@@ -28,14 +32,36 @@ data class ShareState(
val busy: Boolean = false, val busy: Boolean = false,
val error: String? = null, val error: String? = null,
) { ) {
/** The people it isn't shared with yet. */ /** The people and groups it isn't shared with yet, people first. */
val available: List<Member> val available: List<ShareCandidate>
get() { get() {
val taken = shares.map { it.member.id }.toSet() val people = shares.mapNotNull { it.member?.id }.toSet()
return members.filterNot { it.id in taken } 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). * Sharing a note with other people on the linked server (#5175).
* *
@@ -63,9 +89,14 @@ class ShareViewModel(
if (!withContext(Dispatchers.IO) { core.syncStatus().linked }) { if (!withContext(Dispatchers.IO) { core.syncStatus().linked }) {
state.copy(loading = false, needsServer = true) state.copy(loading = false, needsServer = true)
} else { } else {
val members = core.shareDirectory() val directory = core.shareDirectory()
val shares = core.noteShares(noteId) 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) { } catch (e: CoreException) {
state.copy(loading = false, loadError = e.describeShareFailure()) state.copy(loading = false, loadError = e.describeShareFailure())
@@ -77,11 +108,11 @@ class ShareViewModel(
state = ShareState() 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( fun share(
userId: String, target: ShareTarget,
permission: String, 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) } 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_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_none">Not shared with anyone yet.</string>
<string name="share_everyone">Shared with everyone on this server.</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_action">Share</string>
<string name="share_remove">Stop sharing</string> <string name="share_remove">Stop sharing</string>
<string name="share_can_view">Can view</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_by_can_edit">Shared by %1$s · you can edit the text</string>
<string name="share_chip_shared">Shared</string> <string name="share_chip_shared">Shared</string>
<string name="share_chip_by">From %1$s</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 <!-- Attachments. A file is stored on the phone at once and uploaded on a later
sync, so nothing here talks about the network. --> sync, so nothing here talks about the network. -->
+10 -9
View File
@@ -43,8 +43,9 @@ use inkwell_core::sync::blobs::BlobStore;
use inkwell_core::sync::{client, compat, engine, push, sharing, state}; use inkwell_core::sync::{client, compat, engine, push, sharing, state};
use models::{ use models::{
patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Member, Note, NoteDraft, patch_from, BodyItem, BodyTag, ClientUpdate, Directory, Identity, Label, Note, NoteDraft,
NoteEdit, NoteQuery, NoteShare, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus, NoteEdit, NoteQuery, NoteShare, ProbeResult, RevokeOutcome, ShareTarget, SyncOutcome,
SyncStatus,
}; };
uniffi::setup_scaffolding!(); uniffi::setup_scaffolding!();
@@ -612,13 +613,13 @@ impl Inkwell {
// The Share dialog asks the server directly (#5175). Unlinked, each answers // The Share dialog asks the server directly (#5175). Unlinked, each answers
// `NotLinked`, which the dialog turns into "sharing needs a server". // `NotLinked`, which the dialog turns into "sharing needs a server".
/// Everyone on the instance a note can be shared with. /// Everyone on the instance a note can be shared with, and every group.
pub async fn share_directory(&self) -> Result<Vec<Member>, CoreError> { pub async fn share_directory(&self) -> Result<Directory, CoreError> {
self.credentials()?; self.credentials()?;
let members = sharing::directory(&self.db) let directory = sharing::directory(&self.db)
.await .await
.map_err(CoreError::network)?; .map_err(CoreError::network)?;
Ok(members.into_iter().map(Member::from).collect()) Ok(directory.into())
} }
/// Who this note is shared with. /// Who this note is shared with.
@@ -630,15 +631,15 @@ impl Inkwell {
Ok(shares.into_iter().map(NoteShare::from).collect()) 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( pub async fn share_note(
&self, &self,
note_id: String, note_id: String,
user_id: String, target: ShareTarget,
permission: String, permission: String,
) -> Result<Vec<NoteShare>, CoreError> { ) -> Result<Vec<NoteShare>, CoreError> {
self.credentials()?; self.credentials()?;
let shares = sharing::share(&self.db, &note_id, &user_id, &permission) let shares = sharing::share(&self.db, &note_id, &target.into(), &permission)
.await .await
.map_err(CoreError::network)?; .map_err(CoreError::network)?;
Ok(shares.into_iter().map(NoteShare::from).collect()) Ok(shares.into_iter().map(NoteShare::from).collect())
+62 -3
View File
@@ -636,11 +636,68 @@ impl From<core_client::Member> 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<core_client::ShareGroup> 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<Member>,
pub groups: Vec<ShareGroup>,
}
impl From<core_client::Directory> 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<ShareTarget> 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)] #[derive(Debug, Clone, uniffi::Record)]
pub struct NoteShare { pub struct NoteShare {
pub id: String, pub id: String,
pub member: Member, pub member: Option<Member>,
pub group: Option<ShareGroup>,
pub permission: String, pub permission: String,
} }
@@ -650,12 +707,14 @@ impl From<core_client::NoteShare> for NoteShare {
let core_client::NoteShare { let core_client::NoteShare {
id, id,
member, member,
group,
permission, permission,
created_at: _, created_at: _,
} = value; } = value;
NoteShare { NoteShare {
id, id,
member: member.into(), member: member.map(Member::from),
group: group.map(ShareGroup::from),
permission, permission,
} }
} }