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
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:
@@ -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,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = member.displayName.ifBlank { member.email },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
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(),
|
||||
)
|
||||
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 = 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) }
|
||||
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
+10
-9
@@ -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<Vec<Member>, CoreError> {
|
||||
/// Everyone on the instance a note can be shared with, and every group.
|
||||
pub async fn share_directory(&self) -> Result<Directory, CoreError> {
|
||||
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<Vec<NoteShare>, 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())
|
||||
|
||||
@@ -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)]
|
||||
pub struct NoteShare {
|
||||
pub id: String,
|
||||
pub member: Member,
|
||||
pub member: Option<Member>,
|
||||
pub group: Option<ShareGroup>,
|
||||
pub permission: String,
|
||||
}
|
||||
|
||||
@@ -650,12 +707,14 @@ impl From<core_client::NoteShare> 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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user