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

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:
2026-10-07 15:42:30 -04:00
co-authored by Claude Opus 5.5
parent aa36b43dc3
commit 2e7db21b21
9 changed files with 519 additions and 21 deletions
@@ -35,6 +35,8 @@ import com.fabledsword.inkwell.ui.ForegroundTransitions
import com.fabledsword.inkwell.ui.InkwellTheme
import com.fabledsword.inkwell.ui.LocalAttachmentFiles
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.SyncScreen
import com.fabledsword.inkwell.ui.SyncState
@@ -266,6 +268,11 @@ private fun App(
val tags: TagsViewModel =
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 settings = remember(context) { SyncSettings(context) }
var automatic by remember { mutableStateOf(settings.automatic) }
@@ -327,6 +334,7 @@ private fun App(
labels = board.state.labels,
saving = board.state.saving,
error = board.state.error,
onShare = { share.open(editing.id) },
// The one seam between the editor and the store. Exhaustive at the
// other end, so a new action cannot be added without being handled.
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
// editor keeps its own, because it has to save the open note before leaving.
BackHandler(enabled = showingSync) { showingSync = false }
@@ -76,13 +76,18 @@ import com.fabledsword.inkwell.core.Note
fun EditorTopBar(
note: Note,
readOnly: Boolean,
access: NoteAccess,
onClose: () -> Unit,
onStartChecklist: () -> Unit,
onPicker: (Picker) -> Unit,
onAttach: () -> Unit,
onShare: () -> Unit,
onConfirmDelete: () -> 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()
TopAppBar(
title = {},
@@ -98,13 +103,15 @@ fun EditorTopBar(
}
},
actions = {
if (!readOnly) {
if (!readOnly && owner) {
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
Icon(
Icons.Filled.Notifications,
contentDescription = stringResource(R.string.editor_reminder),
)
}
}
if (!readOnly) {
// Inserts `- [ ] ` at the caret. Always available, and never hidden:
// a checklist is text now (M304), so there is no section to be
// already-showing and no reason a second list cannot start further
@@ -115,9 +122,10 @@ fun EditorTopBar(
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
// not be found.
}
// 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 not be found.
if (!readOnly && owner) {
IconButton(onClick = onAttach) {
Icon(
painter = painterResource(R.drawable.ic_attach),
@@ -125,13 +133,16 @@ fun EditorTopBar(
)
}
}
OverflowMenu(
note = note,
readOnly = readOnly,
onPicker = onPicker,
onConfirmDelete = onConfirmDelete,
onAction = onAction,
)
if (owner) {
OverflowMenu(
note = note,
readOnly = readOnly,
onPicker = onPicker,
onShare = onShare,
onConfirmDelete = onConfirmDelete,
onAction = onAction,
)
}
},
// EXPLICIT, and not optional — the same lesson the old bottom bar learned.
// Material derives a bar's content colour from its container via
@@ -269,6 +280,7 @@ private fun OverflowMenu(
note: Note,
readOnly: Boolean,
onPicker: (Picker) -> Unit,
onShare: () -> Unit,
onConfirmDelete: () -> Unit,
onAction: (EditorAction) -> Unit,
) {
@@ -288,6 +300,10 @@ private fun OverflowMenu(
close,
) { onAction(EditorAction.SetPinned(!note.pinned)) }
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(
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
close,
@@ -338,7 +338,7 @@ private fun RecurrenceChips(
}
@Composable
private fun SheetTitle(labelRes: Int) {
internal fun SheetTitle(labelRes: Int) {
Text(
text = stringResource(labelRes),
style = MaterialTheme.typography.titleMedium,
@@ -136,7 +136,12 @@ fun NoteCard(
if (lonePreview != null) {
LinkPreviewCard(preview = lonePreview, compact = false)
} 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,
@@ -172,6 +177,8 @@ fun NoteCard(
Spacer(Modifier.height(8.dp))
ReminderChip(instant = at, recurrence = note.recurrence)
}
SharedChip(note)
}
NoteMenu(
@@ -222,7 +229,9 @@ private fun NoteMenu(
onAction: (EditorAction) -> 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) {
MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) }
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
/** How far one long line of a card may wrap before it is cut. */
@@ -58,6 +58,7 @@ fun NoteEditorScreen(
labels: List<Label>,
saving: Boolean,
error: String?,
onShare: () -> Unit,
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
@@ -90,7 +91,13 @@ fun NoteEditorScreen(
// 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
// 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
// note, read it, back out" write nothing at all — without it every glance
@@ -177,6 +184,7 @@ fun NoteEditorScreen(
EditorTopBar(
note = note,
readOnly = readOnly,
access = access,
onClose = leave,
onStartChecklist = {
val (next, id) = blocks.plusTask()
@@ -185,6 +193,10 @@ fun NoteEditorScreen(
},
onPicker = { picker = it },
onAttach = { pickFiles.launch(ANY_TYPE) },
onShare = {
flush()
onShare()
},
onConfirmDelete = { confirmingDelete = true },
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
// 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
@@ -243,24 +258,28 @@ fun NoteEditorScreen(
// list on screen twice.
if (note.labels.isNotEmpty()) {
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
EditorLabelRow(note = note, readOnly = !ownerControls, onAction = onAction)
}
note.remindAt?.let { at ->
EditorReminderRow(
at = at,
recurrence = note.recurrence,
readOnly = readOnly,
readOnly = !ownerControls,
onAction = onAction,
)
}
if (note.attachments.isNotEmpty()) {
EditorAttachments(attachments = note.attachments, readOnly = readOnly, onAction = onAction)
EditorAttachments(
attachments = note.attachments,
readOnly = !ownerControls,
onAction = onAction,
)
}
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_restore">Restore</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
sync, so nothing here talks about the network. -->
+6 -2
View File
@@ -1314,7 +1314,9 @@ mod tests {
fn a_view_share_changes_nothing_here() {
let conn = db();
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.
assert_eq!(refused.to_string(), VIEW_ONLY);
assert!(add_item(&conn, "n", "eggs").is_err());
@@ -1338,7 +1340,9 @@ mod tests {
assert!(dirty(&conn, "n"));
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!(update_note(&conn, "n", &json!({ "body": "x", "archived": true })).is_err());
assert!(trash(&conn, "n").is_err());