android: share a note, and read or edit one shared with you
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 18s
CI & Build / integration (push) Successful in 1m25s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m39s
Android / Kotlin + Rust (APK) (push) Failing after 4m53s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m42s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m40s
Desktop (Tauri) / Update manifest (push) Successful in 5s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 18s
CI & Build / integration (push) Successful in 1m25s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m39s
Android / Kotlin + Rust (APK) (push) Failing after 4m53s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m42s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m40s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The editor's menu has Share…, which opens a sheet of who the note is shared
with and lets you add someone at view or edit, change it, or stop sharing;
unlinked, it says sharing needs a server. A note shared to view opens
read-only; at edit only its text can change. Cards say who shared a note
("From Robin") or that yours is shared, and a view-only note's boxes don't
tick.
Also: two core store tests used unwrap_err on a Result<Note>, which needs
Note: Debug; they use err().expect() now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,8 @@ import com.fabledsword.inkwell.ui.ForegroundTransitions
|
|||||||
import com.fabledsword.inkwell.ui.InkwellTheme
|
import com.fabledsword.inkwell.ui.InkwellTheme
|
||||||
import com.fabledsword.inkwell.ui.LocalAttachmentFiles
|
import com.fabledsword.inkwell.ui.LocalAttachmentFiles
|
||||||
import com.fabledsword.inkwell.ui.NoteEditorScreen
|
import com.fabledsword.inkwell.ui.NoteEditorScreen
|
||||||
|
import com.fabledsword.inkwell.ui.ShareSheet
|
||||||
|
import com.fabledsword.inkwell.ui.ShareViewModel
|
||||||
import com.fabledsword.inkwell.ui.StoreUnavailableScreen
|
import com.fabledsword.inkwell.ui.StoreUnavailableScreen
|
||||||
import com.fabledsword.inkwell.ui.SyncScreen
|
import com.fabledsword.inkwell.ui.SyncScreen
|
||||||
import com.fabledsword.inkwell.ui.SyncState
|
import com.fabledsword.inkwell.ui.SyncState
|
||||||
@@ -266,6 +268,11 @@ private fun App(
|
|||||||
val tags: TagsViewModel =
|
val tags: TagsViewModel =
|
||||||
viewModel(factory = TagsViewModel.factory(core, onStoreChanged = board::refreshLabels))
|
viewModel(factory = TagsViewModel.factory(core, onStoreChanged = board::refreshLabels))
|
||||||
|
|
||||||
|
// A share landing changes the note's `shared` flag, which the board's card chip
|
||||||
|
// reads, so the board reloads after one.
|
||||||
|
val share: ShareViewModel =
|
||||||
|
viewModel(factory = ShareViewModel.factory(core, onStoreChanged = board::refresh))
|
||||||
|
|
||||||
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
|
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
|
||||||
val settings = remember(context) { SyncSettings(context) }
|
val settings = remember(context) { SyncSettings(context) }
|
||||||
var automatic by remember { mutableStateOf(settings.automatic) }
|
var automatic by remember { mutableStateOf(settings.automatic) }
|
||||||
@@ -327,6 +334,7 @@ private fun App(
|
|||||||
labels = board.state.labels,
|
labels = board.state.labels,
|
||||||
saving = board.state.saving,
|
saving = board.state.saving,
|
||||||
error = board.state.error,
|
error = board.state.error,
|
||||||
|
onShare = { share.open(editing.id) },
|
||||||
// The one seam between the editor and the store. Exhaustive at the
|
// The one seam between the editor and the store. Exhaustive at the
|
||||||
// other end, so a new action cannot be added without being handled.
|
// other end, so a new action cannot be added without being handled.
|
||||||
onAction = { board.onEditorAction(editing, it) },
|
onAction = { board.onEditorAction(editing, it) },
|
||||||
@@ -376,6 +384,16 @@ private fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Over whichever screen opened it; a ModalBottomSheet handles its own back.
|
||||||
|
if (share.state.noteId != null) {
|
||||||
|
ShareSheet(
|
||||||
|
state = share.state,
|
||||||
|
onShare = share::share,
|
||||||
|
onUnshare = share::unshare,
|
||||||
|
onDismiss = share::close,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// The sync screen has no back handler of its own, so one lives here. The
|
// The sync screen has no back handler of its own, so one lives here. The
|
||||||
// editor keeps its own, because it has to save the open note before leaving.
|
// editor keeps its own, because it has to save the open note before leaving.
|
||||||
BackHandler(enabled = showingSync) { showingSync = false }
|
BackHandler(enabled = showingSync) { showingSync = false }
|
||||||
|
|||||||
@@ -76,13 +76,18 @@ import com.fabledsword.inkwell.core.Note
|
|||||||
fun EditorTopBar(
|
fun EditorTopBar(
|
||||||
note: Note,
|
note: Note,
|
||||||
readOnly: Boolean,
|
readOnly: Boolean,
|
||||||
|
access: NoteAccess,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onStartChecklist: () -> Unit,
|
onStartChecklist: () -> Unit,
|
||||||
onPicker: (Picker) -> Unit,
|
onPicker: (Picker) -> Unit,
|
||||||
onAttach: () -> Unit,
|
onAttach: () -> Unit,
|
||||||
|
onShare: () -> Unit,
|
||||||
onConfirmDelete: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
onAction: (EditorAction) -> Unit,
|
onAction: (EditorAction) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
// Someone else's note (#5175): its text is all that may change here, so the bar
|
||||||
|
// keeps the checklist button and nothing that is the owner's to do.
|
||||||
|
val owner = access == NoteAccess.OWNER
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {},
|
title = {},
|
||||||
@@ -98,13 +103,15 @@ fun EditorTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (!readOnly) {
|
if (!readOnly && owner) {
|
||||||
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
|
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Notifications,
|
Icons.Filled.Notifications,
|
||||||
contentDescription = stringResource(R.string.editor_reminder),
|
contentDescription = stringResource(R.string.editor_reminder),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (!readOnly) {
|
||||||
// Inserts `- [ ] ` at the caret. Always available, and never hidden:
|
// Inserts `- [ ] ` at the caret. Always available, and never hidden:
|
||||||
// a checklist is text now (M304), so there is no section to be
|
// a checklist is text now (M304), so there is no section to be
|
||||||
// already-showing and no reason a second list cannot start further
|
// already-showing and no reason a second list cannot start further
|
||||||
@@ -115,9 +122,10 @@ fun EditorTopBar(
|
|||||||
contentDescription = stringResource(R.string.editor_add_checklist),
|
contentDescription = stringResource(R.string.editor_add_checklist),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// On the bar rather than in the overflow: attaching a photo is
|
}
|
||||||
// something people look for, and a menu of words is where it would
|
// On the bar rather than in the overflow: attaching a photo is something
|
||||||
// not be found.
|
// people look for, and a menu of words is where it would not be found.
|
||||||
|
if (!readOnly && owner) {
|
||||||
IconButton(onClick = onAttach) {
|
IconButton(onClick = onAttach) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_attach),
|
painter = painterResource(R.drawable.ic_attach),
|
||||||
@@ -125,13 +133,16 @@ fun EditorTopBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OverflowMenu(
|
if (owner) {
|
||||||
note = note,
|
OverflowMenu(
|
||||||
readOnly = readOnly,
|
note = note,
|
||||||
onPicker = onPicker,
|
readOnly = readOnly,
|
||||||
onConfirmDelete = onConfirmDelete,
|
onPicker = onPicker,
|
||||||
onAction = onAction,
|
onShare = onShare,
|
||||||
)
|
onConfirmDelete = onConfirmDelete,
|
||||||
|
onAction = onAction,
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// EXPLICIT, and not optional — the same lesson the old bottom bar learned.
|
// EXPLICIT, and not optional — the same lesson the old bottom bar learned.
|
||||||
// Material derives a bar's content colour from its container via
|
// Material derives a bar's content colour from its container via
|
||||||
@@ -269,6 +280,7 @@ private fun OverflowMenu(
|
|||||||
note: Note,
|
note: Note,
|
||||||
readOnly: Boolean,
|
readOnly: Boolean,
|
||||||
onPicker: (Picker) -> Unit,
|
onPicker: (Picker) -> Unit,
|
||||||
|
onShare: () -> Unit,
|
||||||
onConfirmDelete: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
onAction: (EditorAction) -> Unit,
|
onAction: (EditorAction) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -288,6 +300,10 @@ private fun OverflowMenu(
|
|||||||
close,
|
close,
|
||||||
) { onAction(EditorAction.SetPinned(!note.pinned)) }
|
) { onAction(EditorAction.SetPinned(!note.pinned)) }
|
||||||
MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) }
|
MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) }
|
||||||
|
// Not on a draft: the server has nothing to share until it is saved.
|
||||||
|
if (note.id != DRAFT_ID) {
|
||||||
|
MenuItem(R.string.editor_share, close, onShare)
|
||||||
|
}
|
||||||
MenuItem(
|
MenuItem(
|
||||||
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
|
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
|
||||||
close,
|
close,
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ private fun RecurrenceChips(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SheetTitle(labelRes: Int) {
|
internal fun SheetTitle(labelRes: Int) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(labelRes),
|
text = stringResource(labelRes),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
|||||||
@@ -136,7 +136,12 @@ fun NoteCard(
|
|||||||
if (lonePreview != null) {
|
if (lonePreview != null) {
|
||||||
LinkPreviewCard(preview = lonePreview, compact = false)
|
LinkPreviewCard(preview = lonePreview, compact = false)
|
||||||
} else if (note.body.isNotBlank()) {
|
} else if (note.body.isNotBlank()) {
|
||||||
NoteBody(note = note, onToggleItem = onToggleItem)
|
// A note shared with us to view has inert boxes (#5175): ticking one is
|
||||||
|
// changing its text, which is not ours to do.
|
||||||
|
NoteBody(
|
||||||
|
note = note,
|
||||||
|
onToggleItem = if (note.access == NoteAccess.VIEW) INERT_TOGGLE else onToggleItem,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Links mentioned INSIDE a note: a compact strip at the foot of the card,
|
// Links mentioned INSIDE a note: a compact strip at the foot of the card,
|
||||||
@@ -172,6 +177,8 @@ fun NoteCard(
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
ReminderChip(instant = at, recurrence = note.recurrence)
|
ReminderChip(instant = at, recurrence = note.recurrence)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SharedChip(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
NoteMenu(
|
NoteMenu(
|
||||||
@@ -222,7 +229,9 @@ private fun NoteMenu(
|
|||||||
onAction: (EditorAction) -> Unit,
|
onAction: (EditorAction) -> Unit,
|
||||||
onConfirmDelete: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
// Every item here is the owner's to do, so someone else's note has no menu at all
|
||||||
|
// rather than an empty one (#5175).
|
||||||
|
DropdownMenu(expanded = expanded && note.access == NoteAccess.OWNER, onDismissRequest = onDismiss) {
|
||||||
if (note.trashed) {
|
if (note.trashed) {
|
||||||
MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) }
|
MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) }
|
||||||
MenuItem(R.string.editor_delete_forever, onDismiss, onConfirmDelete)
|
MenuItem(R.string.editor_delete_forever, onDismiss, onConfirmDelete)
|
||||||
@@ -445,6 +454,42 @@ private fun ReminderChip(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whose note this is, when it is not only ours (#5175): "From Robin" on a note someone
|
||||||
|
* shared with us, "Shared" on one of ours that we shared. Nothing on a private note.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SharedChip(note: Note) {
|
||||||
|
val text =
|
||||||
|
when {
|
||||||
|
note.access != NoteAccess.OWNER ->
|
||||||
|
stringResource(
|
||||||
|
R.string.share_chip_by,
|
||||||
|
note.sharedBy?.displayName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.share_someone),
|
||||||
|
)
|
||||||
|
note.shared -> stringResource(R.string.share_chip_shared)
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val tint = noteTint("default")
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = tint.chipForeground(dark),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(CHIP_RADIUS))
|
||||||
|
.background(tint.chipBackground(dark))
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A box that does nothing when tapped: someone else's note, shared to view. */
|
||||||
|
private val INERT_TOGGLE: (Int, Boolean) -> Unit = { _, _ -> }
|
||||||
|
|
||||||
private const val MAX_PREVIEW_LINES = 8
|
private const val MAX_PREVIEW_LINES = 8
|
||||||
|
|
||||||
/** How far one long line of a card may wrap before it is cut. */
|
/** How far one long line of a card may wrap before it is cut. */
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ fun NoteEditorScreen(
|
|||||||
labels: List<Label>,
|
labels: List<Label>,
|
||||||
saving: Boolean,
|
saving: Boolean,
|
||||||
error: String?,
|
error: String?,
|
||||||
|
onShare: () -> Unit,
|
||||||
onAction: (EditorAction) -> Unit,
|
onAction: (EditorAction) -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
@@ -90,7 +91,13 @@ fun NoteEditorScreen(
|
|||||||
// A note in the trash is a record, not a document: editing one would silently
|
// A note in the trash is a record, not a document: editing one would silently
|
||||||
// resurrect work that was meant to be thrown away. It renders read-only, with
|
// resurrect work that was meant to be thrown away. It renders read-only, with
|
||||||
// Restore and Delete forever as the only things to do with it.
|
// Restore and Delete forever as the only things to do with it.
|
||||||
val readOnly = note.trashed
|
//
|
||||||
|
// A note shared with us to view is read-only for the same reason it is on the
|
||||||
|
// web: nothing about it is ours to change (#5175). At edit, the text is, and the
|
||||||
|
// rows that are the owner's (tags, reminder, files, previews) stay inert.
|
||||||
|
val access = note.access
|
||||||
|
val readOnly = note.readOnlyHere
|
||||||
|
val ownerControls = note.ownerControlsHere
|
||||||
|
|
||||||
// Persist the text, if it changed. The baseline check is what makes "open a
|
// Persist the text, if it changed. The baseline check is what makes "open a
|
||||||
// note, read it, back out" write nothing at all — without it every glance
|
// note, read it, back out" write nothing at all — without it every glance
|
||||||
@@ -177,6 +184,7 @@ fun NoteEditorScreen(
|
|||||||
EditorTopBar(
|
EditorTopBar(
|
||||||
note = note,
|
note = note,
|
||||||
readOnly = readOnly,
|
readOnly = readOnly,
|
||||||
|
access = access,
|
||||||
onClose = leave,
|
onClose = leave,
|
||||||
onStartChecklist = {
|
onStartChecklist = {
|
||||||
val (next, id) = blocks.plusTask()
|
val (next, id) = blocks.plusTask()
|
||||||
@@ -185,6 +193,10 @@ fun NoteEditorScreen(
|
|||||||
},
|
},
|
||||||
onPicker = { picker = it },
|
onPicker = { picker = it },
|
||||||
onAttach = { pickFiles.launch(ANY_TYPE) },
|
onAttach = { pickFiles.launch(ANY_TYPE) },
|
||||||
|
onShare = {
|
||||||
|
flush()
|
||||||
|
onShare()
|
||||||
|
},
|
||||||
onConfirmDelete = { confirmingDelete = true },
|
onConfirmDelete = { confirmingDelete = true },
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
)
|
)
|
||||||
@@ -225,6 +237,9 @@ fun NoteEditorScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whose note this is, and what may be done with it here.
|
||||||
|
SharedByLine(note)
|
||||||
|
|
||||||
// A note is its body; its NAME is that body's first line, so there
|
// A note is its body; its NAME is that body's first line, so there
|
||||||
// is nothing separate to type into and nothing rendered bolder than
|
// is nothing separate to type into and nothing rendered bolder than
|
||||||
// the line beneath it (M13 steps 3 and 4). What 2992 changed is only
|
// the line beneath it (M13 steps 3 and 4). What 2992 changed is only
|
||||||
@@ -243,24 +258,28 @@ fun NoteEditorScreen(
|
|||||||
// list on screen twice.
|
// list on screen twice.
|
||||||
|
|
||||||
if (note.labels.isNotEmpty()) {
|
if (note.labels.isNotEmpty()) {
|
||||||
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
|
EditorLabelRow(note = note, readOnly = !ownerControls, onAction = onAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
note.remindAt?.let { at ->
|
note.remindAt?.let { at ->
|
||||||
EditorReminderRow(
|
EditorReminderRow(
|
||||||
at = at,
|
at = at,
|
||||||
recurrence = note.recurrence,
|
recurrence = note.recurrence,
|
||||||
readOnly = readOnly,
|
readOnly = !ownerControls,
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (note.attachments.isNotEmpty()) {
|
if (note.attachments.isNotEmpty()) {
|
||||||
EditorAttachments(attachments = note.attachments, readOnly = readOnly, onAction = onAction)
|
EditorAttachments(
|
||||||
|
attachments = note.attachments,
|
||||||
|
readOnly = !ownerControls,
|
||||||
|
onAction = onAction,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (note.previews.isNotEmpty()) {
|
if (note.previews.isNotEmpty()) {
|
||||||
EditorPreviews(previews = note.previews, readOnly = readOnly, onAction = onAction)
|
EditorPreviews(previews = note.previews, readOnly = !ownerControls, onAction = onAction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
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 kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** Everything the Share sheet renders from. [noteId] null means the sheet is closed. */
|
||||||
|
data class ShareState(
|
||||||
|
val noteId: String? = null,
|
||||||
|
val loading: Boolean = false,
|
||||||
|
val members: List<Member> = emptyList(),
|
||||||
|
val shares: List<NoteShare> = emptyList(),
|
||||||
|
/** This device has no server, and sharing is between people on one. */
|
||||||
|
val needsServer: Boolean = false,
|
||||||
|
/** Why the sheet couldn't load, when it has a server and still failed. */
|
||||||
|
val loadError: String? = null,
|
||||||
|
/** An in-flight write, for disabling the controls that would race it. */
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
) {
|
||||||
|
/** The people it isn't shared with yet. */
|
||||||
|
val available: List<Member>
|
||||||
|
get() {
|
||||||
|
val taken = shares.map { it.member.id }.toSet()
|
||||||
|
return members.filterNot { it.id in taken }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sharing a note with other people on the linked server (#5175).
|
||||||
|
*
|
||||||
|
* A peer of the web's `ShareDialog.vue`. Shares belong to the server, so every call
|
||||||
|
* here is a network round-trip through the core — `suspend` functions on uniffi's
|
||||||
|
* tokio runtime, not blocking store calls, so no IO dispatcher is needed. The one
|
||||||
|
* thing the core keeps locally is the note's `shared` flag, which is why a write
|
||||||
|
* that landed tells the board to reload: its card chip reads that flag.
|
||||||
|
*/
|
||||||
|
class ShareViewModel(
|
||||||
|
private val core: Inkwell,
|
||||||
|
/** Called after a share was granted, changed or removed. */
|
||||||
|
private val onStoreChanged: () -> Unit,
|
||||||
|
) : ViewModel() {
|
||||||
|
var state by mutableStateOf(ShareState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun open(noteId: String) {
|
||||||
|
state = ShareState(noteId = noteId, loading = true)
|
||||||
|
viewModelScope.launch {
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val members = core.shareDirectory()
|
||||||
|
val shares = core.noteShares(noteId)
|
||||||
|
state.copy(loading = false, members = members, shares = shares)
|
||||||
|
} catch (e: CoreException) {
|
||||||
|
// Unlinked is not a failure: it is the phone working as intended,
|
||||||
|
// and the sheet says what sharing needs instead of an error.
|
||||||
|
if (e is CoreException.NotLinked) {
|
||||||
|
state.copy(loading = false, needsServer = true)
|
||||||
|
} else {
|
||||||
|
state.copy(loading = false, loadError = e.describeShareFailure())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
state = ShareState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Share with someone, or change what they may do. */
|
||||||
|
fun share(
|
||||||
|
userId: String,
|
||||||
|
permission: String,
|
||||||
|
) = write { noteId -> core.shareNote(noteId, userId, permission) }
|
||||||
|
|
||||||
|
fun unshare(shareId: String) = write { noteId -> core.unshareNote(noteId, shareId) }
|
||||||
|
|
||||||
|
private fun write(call: suspend (String) -> List<NoteShare>) {
|
||||||
|
val noteId = state.noteId ?: return
|
||||||
|
state = state.copy(busy = true, error = null)
|
||||||
|
viewModelScope.launch {
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val shares = call(noteId)
|
||||||
|
onStoreChanged()
|
||||||
|
state.copy(busy = false, shares = shares)
|
||||||
|
} catch (e: CoreException) {
|
||||||
|
state.copy(busy = false, error = e.describeShareFailure())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun factory(
|
||||||
|
core: Inkwell,
|
||||||
|
onStoreChanged: () -> Unit,
|
||||||
|
): ViewModelProvider.Factory =
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T = ShareViewModel(core, onStoreChanged) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The core's message is written to be shown ("The server doesn't have this note yet…"). */
|
||||||
|
private fun Throwable.describeShareFailure(): String = message ?: "Something went wrong."
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
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.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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How this account holds a note (#5175), read from the core's `permission`.
|
||||||
|
*
|
||||||
|
* Someone else's note at [EDIT] is theirs to have its TEXT changed here and nothing
|
||||||
|
* else; at [VIEW] nothing changes here at all. The core refuses the rest anyway —
|
||||||
|
* this only keeps the editor from offering what would be refused.
|
||||||
|
*/
|
||||||
|
enum class NoteAccess { OWNER, EDIT, VIEW }
|
||||||
|
|
||||||
|
val Note.access: NoteAccess
|
||||||
|
get() =
|
||||||
|
when (permission) {
|
||||||
|
"edit" -> NoteAccess.EDIT
|
||||||
|
"view" -> NoteAccess.VIEW
|
||||||
|
else -> NoteAccess.OWNER
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nothing changes here: the note is in the trash, or shared with us to view. */
|
||||||
|
val Note.readOnlyHere: Boolean
|
||||||
|
get() = trashed || access == NoteAccess.VIEW
|
||||||
|
|
||||||
|
/** The owner's own controls apply: tags, reminder, files, previews, the menu. */
|
||||||
|
val Note.ownerControlsHere: Boolean
|
||||||
|
get() = !readOnlyHere && access == NoteAccess.OWNER
|
||||||
|
|
||||||
|
/** "Shared by Robin · view only" over someone else's note; nothing over our own. */
|
||||||
|
@Composable
|
||||||
|
fun SharedByLine(note: Note) {
|
||||||
|
if (note.access == NoteAccess.OWNER) return
|
||||||
|
val who = note.sharedBy?.displayName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.share_someone)
|
||||||
|
val line =
|
||||||
|
if (note.access == NoteAccess.EDIT) {
|
||||||
|
stringResource(R.string.share_by_can_edit, who)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.share_by_view_only, who)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = line,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val VIEW = "view"
|
||||||
|
private const val EDIT = "edit"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Share sheet: who the note is shared with, and adding someone.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ShareSheet(
|
||||||
|
state: ShareState,
|
||||||
|
onShare: (userId: String, permission: String) -> Unit,
|
||||||
|
onUnshare: (shareId: String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
SheetTitle(R.string.share_title)
|
||||||
|
when {
|
||||||
|
state.loading -> Muted(stringResource(R.string.share_loading))
|
||||||
|
state.needsServer -> Muted(stringResource(R.string.share_needs_server))
|
||||||
|
state.loadError != null -> ErrorText(state.loadError)
|
||||||
|
else -> ShareBody(state, onShare, onUnshare)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ShareBody(
|
||||||
|
state: ShareState,
|
||||||
|
onShare: (userId: String, permission: String) -> Unit,
|
||||||
|
onUnshare: (shareId: String) -> Unit,
|
||||||
|
) {
|
||||||
|
if (state.shares.isEmpty()) {
|
||||||
|
Muted(stringResource(R.string.share_none))
|
||||||
|
}
|
||||||
|
state.shares.forEach { share ->
|
||||||
|
ShareRow(
|
||||||
|
share = share,
|
||||||
|
busy = state.busy,
|
||||||
|
onPermission = { onShare(share.member.id, it) },
|
||||||
|
onRemove = { onUnshare(share.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.available.isEmpty()) {
|
||||||
|
Muted(stringResource(R.string.share_everyone))
|
||||||
|
} else {
|
||||||
|
AddPerson(state = state, onShare = onShare)
|
||||||
|
}
|
||||||
|
state.error?.let { ErrorText(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ShareRow(
|
||||||
|
share: NoteShare,
|
||||||
|
busy: Boolean,
|
||||||
|
onPermission: (String) -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
MemberName(share.member, Modifier.weight(1f))
|
||||||
|
IconButton(onClick = onRemove, enabled = !busy) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.share_remove))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PermissionChips(selected = share.permission, enabled = !busy, onSelect = onPermission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddPerson(
|
||||||
|
state: ShareState,
|
||||||
|
onShare: (userId: String, permission: String) -> Unit,
|
||||||
|
) {
|
||||||
|
var pick by remember(state.noteId) { mutableStateOf<String?>(null) }
|
||||||
|
var permission by remember(state.noteId) { mutableStateOf(VIEW) }
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.share_add),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
// 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 ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable { pick = member.id },
|
||||||
|
) {
|
||||||
|
RadioButton(selected = pick == member.id, onClick = { pick = member.id })
|
||||||
|
MemberName(member, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
PermissionChips(selected = permission, enabled = !state.busy, onSelect = { permission = it })
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
TextButton(
|
||||||
|
enabled = pick != null && !state.busy,
|
||||||
|
onClick = {
|
||||||
|
pick?.let { onShare(it, permission) }
|
||||||
|
pick = null
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.share_action)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PermissionChips(
|
||||||
|
selected: String,
|
||||||
|
enabled: Boolean,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selected == VIEW,
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = { onSelect(VIEW) },
|
||||||
|
label = { Text(stringResource(R.string.share_can_view)) },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selected == EDIT,
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = { onSelect(EDIT) },
|
||||||
|
label = { Text(stringResource(R.string.share_can_edit)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MemberName(
|
||||||
|
member: Member,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = member.displayName.ifBlank { member.email },
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
if (member.displayName.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = member.email,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Muted(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorText(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MEMBER_LIST_MAX_HEIGHT = 240.dp
|
||||||
@@ -58,6 +58,25 @@
|
|||||||
<string name="editor_trash">Move to trash</string>
|
<string name="editor_trash">Move to trash</string>
|
||||||
<string name="editor_restore">Restore</string>
|
<string name="editor_restore">Restore</string>
|
||||||
<string name="editor_cancel">Cancel</string>
|
<string name="editor_cancel">Cancel</string>
|
||||||
|
<string name="editor_share">Share…</string>
|
||||||
|
|
||||||
|
<!-- Sharing (#5175). Shares live on the server, so the sheet says so when this
|
||||||
|
phone isn't linked to one. -->
|
||||||
|
<string name="share_title">Share</string>
|
||||||
|
<string name="share_loading">Loading…</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_everyone">Shared with everyone on this server.</string>
|
||||||
|
<string name="share_add">Add someone</string>
|
||||||
|
<string name="share_action">Share</string>
|
||||||
|
<string name="share_remove">Stop sharing</string>
|
||||||
|
<string name="share_can_view">Can view</string>
|
||||||
|
<string name="share_can_edit">Can edit</string>
|
||||||
|
<string name="share_someone">someone</string>
|
||||||
|
<string name="share_by_view_only">Shared by %1$s · view only</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_by">From %1$s</string>
|
||||||
|
|
||||||
<!-- 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. -->
|
||||||
|
|||||||
@@ -1314,7 +1314,9 @@ mod tests {
|
|||||||
fn a_view_share_changes_nothing_here() {
|
fn a_view_share_changes_nothing_here() {
|
||||||
let conn = db();
|
let conn = db();
|
||||||
shared(&conn, "n", "view");
|
shared(&conn, "n", "view");
|
||||||
let refused = update_note(&conn, "n", &json!({ "body": "mine now" })).unwrap_err();
|
let refused = update_note(&conn, "n", &json!({ "body": "mine now" }))
|
||||||
|
.err()
|
||||||
|
.expect("refused");
|
||||||
// The words the person sees, exactly: the refusal prints as its message.
|
// The words the person sees, exactly: the refusal prints as its message.
|
||||||
assert_eq!(refused.to_string(), VIEW_ONLY);
|
assert_eq!(refused.to_string(), VIEW_ONLY);
|
||||||
assert!(add_item(&conn, "n", "eggs").is_err());
|
assert!(add_item(&conn, "n", "eggs").is_err());
|
||||||
@@ -1338,7 +1340,9 @@ mod tests {
|
|||||||
assert!(dirty(&conn, "n"));
|
assert!(dirty(&conn, "n"));
|
||||||
add_item(&conn, "n", "eggs").expect("an item is text");
|
add_item(&conn, "n", "eggs").expect("an item is text");
|
||||||
|
|
||||||
let pinned = update_note(&conn, "n", &json!({ "pinned": true })).unwrap_err();
|
let pinned = update_note(&conn, "n", &json!({ "pinned": true }))
|
||||||
|
.err()
|
||||||
|
.expect("refused");
|
||||||
assert_eq!(pinned.to_string(), OWNER_ONLY);
|
assert_eq!(pinned.to_string(), OWNER_ONLY);
|
||||||
assert!(update_note(&conn, "n", &json!({ "body": "x", "archived": true })).is_err());
|
assert!(update_note(&conn, "n", &json!({ "body": "x", "archived": true })).is_err());
|
||||||
assert!(trash(&conn, "n").is_err());
|
assert!(trash(&conn, "n").is_err());
|
||||||
|
|||||||
Reference in New Issue
Block a user