diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index dd0db67..145a2f6 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -423,6 +423,15 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie } } + /** Call a share something else. The library, history and downloads stay. */ + fun renameShare(id: String, name: String) { + viewModelScope.launch { + runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().renameShare(id, name) } } + .onFailure(::reportError) + reload() + } + } + fun removeShare(id: String) { viewModelScope.launch { runCatching { diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index 0f9fd11..e49c74b 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -310,6 +310,7 @@ fun ProtonStreamApp( addFolder = model::addAccountFolder, onError = model::reportError, ), + onRename = model::renameShare, ) Destination.History -> HistoryScreen( state, diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt index bfe4225..560c88b 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp import io.narl.protonstream.ui.theme.AccentButton import io.narl.protonstream.ui.theme.DangerButton import io.narl.protonstream.ui.theme.TonalButton +import androidx.compose.material.icons.filled.Edit import uniffi.pstr_android.AccountState import uniffi.pstr_android.ShareRecord import androidx.compose.foundation.layout.Box @@ -70,8 +71,10 @@ internal fun SharesScreen( padding: PaddingValues, account: AccountUiState = AccountUiState(), accountActions: AccountActions? = null, + onRename: (String, String) -> Unit = { _, _ -> }, ) { var showAdd by remember { mutableStateOf(false) } + var renaming by remember { mutableStateOf(null) } var signingIn by remember { mutableStateOf(false) } var browsing by remember { mutableStateOf(false) } var repairing by remember { mutableStateOf(null) } @@ -108,6 +111,7 @@ internal fun SharesScreen( onRefresh = { onRefresh(share.id) }, onRepair = { repairing = share }, onRemove = { removing = share }, + onRename = { renaming = share }, ) } } @@ -146,6 +150,21 @@ internal fun SharesScreen( onRepair = { url, password -> onRepair(share.id, url, password) }, ) } + renaming?.let { share -> + var name by remember(share.id) { mutableStateOf(share.name) } + AlertDialog( + onDismissRequest = { renaming = null }, + title = { Text("Rename ${share.name}") }, + text = { OutlinedTextField(name, { name = it }, label = { Text("Name") }, singleLine = true) }, + confirmButton = { + AccentButton(enabled = name.isNotBlank(), onClick = { + renaming = null + onRename(share.id, name.trim()) + }) { Text("Save") } + }, + dismissButton = { TonalButton(onClick = { renaming = null }) { Text("Cancel") } }, + ) + } removing?.let { share -> // Asked, because it cannot be undone: the link's secret and its // offline files go. Watch positions stay (`Catalog::remove_share`). @@ -197,6 +216,7 @@ private fun ShareRow( onRefresh: () -> Unit, onRepair: () -> Unit, onRemove: () -> Unit, + onRename: () -> Unit = {}, ) { var menu by remember { mutableStateOf(false) } ListItem( @@ -244,6 +264,11 @@ private fun ShareRow( leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) }, onClick = { menu = false; onRefresh() }, ) + DropdownMenuItem( + text = { Text("Rename") }, + leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }, + onClick = { menu = false; onRename() }, + ) // Not a remove: a share whose secret has become unreadable // cannot be removed either, since removal deletes a secret // the store can no longer touch. diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index 0d1c457..ae7c7b5 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -1386,6 +1386,14 @@ impl AndroidEngine { Ok(share_record(share)) } + /// Call a share something else; see `ShareStore::rename`. + pub fn rename_share(&self, share_id: String, name: String) -> Result { + self.store + .rename(&share_id, &name) + .map(share_record) + .map_err(BridgeError::from_display) + } + pub fn remove_share(&self, share_id: String) -> Result<(), BridgeError> { let _publication = self.share_publication.lock(); let catalog = self.catalog.lock(); diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index a153725..09cdc81 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -146,6 +146,11 @@ pub enum Action { OpenShareForm, /// Stop offering posters and synopses on the library page. DismissMetadataOffer, + /// Call a share something else. + RenameShare { + id: String, + name: String, + }, /// Store a new link, and password, for a share already in the library. RepairShare { id: String, @@ -563,6 +568,9 @@ pub struct ShareForm { pub repairing: Option, pub repair_url: String, pub repair_password: String, + /// The share being renamed in its own row, if any, and the name so far. + pub renaming: Option, + pub rename_to: String, } pub struct App { @@ -1671,6 +1679,10 @@ impl App { self.form.error = None; self.note(ctx, "share link pasted — add it, or name it first", false); } + Action::RenameShare { id, name } => { + self.form.renaming = None; + self.engine.rename_share(id, name); + } Action::RepairShare { id, url, password } => { self.crawling = true; self.form.repairing = None; diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index 8890a50..63641c0 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -696,6 +696,16 @@ impl Engine { }); } + /// Call a share something else. See [`ShareStore::rename`]. + pub fn rename_share(&self, id: String, name: String) { + let engine = self.clone(); + self.runtime + .spawn_blocking(move || match engine.store.rename(&id, &name) { + Ok(_) => engine.load_shares(), + Err(error) => engine.fail("rename the share", error), + }); + } + /// Re-supply the link behind a share whose stored secret is unreadable — /// a keyring that was reset — or whose custom password changed, keeping /// its catalog, watch state and downloads. See diff --git a/crates/pstr-app/src/ui/shares.rs b/crates/pstr-app/src/ui/shares.rs index bb2b75a..41e8812 100644 --- a/crates/pstr-app/src/ui/shares.rs +++ b/crates/pstr-app/src/ui/shares.rs @@ -108,6 +108,10 @@ fn share_row( if ui.button("Crawl").clicked() { actions.push(Action::Crawl(Some(share.id.clone()))); } + if ui.button("Rename").clicked() { + form.renaming = Some(share.id.clone()); + form.rename_to = share.name.clone(); + } // Not for a folder of the account, which has no link. if share.folder.is_none() && ui @@ -127,10 +131,41 @@ fn share_row( if form.repairing.as_deref() == Some(share.id.as_str()) { repair_form(ui, share, form, actions); } + if form.renaming.as_deref() == Some(share.id.as_str()) { + rename_form(ui, share, form, actions); + } }); ui.add_space(theme::space::S); } +/// A new name for a share, in its own row. Enter saves, as everywhere else. +fn rename_form(ui: &mut egui::Ui, share: &Share, form: &mut ShareForm, actions: &mut Vec) { + ui.add_space(theme::space::M); + ui.horizontal(|ui| { + let field = ui.add( + egui::TextEdit::singleline(&mut form.rename_to) + .hint_text("Name") + .desired_width(320.0), + ); + let ready = !form.rename_to.trim().is_empty(); + let entered = field.lost_focus() && ui_enter_pressed(ui.ctx()); + if ui + .add_enabled_ui(ready, |ui| ui::accent_button(ui, "Save")) + .inner + .clicked() + || (ready && entered) + { + actions.push(Action::RenameShare { + id: share.id.clone(), + name: form.rename_to.trim().to_owned(), + }); + } + if ui.button("Cancel").clicked() { + form.renaming = None; + } + }); +} + /// The same link again, and a password if it has one, for a share already in /// the library. fn repair_form(ui: &mut egui::Ui, share: &Share, form: &mut ShareForm, actions: &mut Vec) { diff --git a/crates/pstr-core/src/shares.rs b/crates/pstr-core/src/shares.rs index 6339810..e657934 100644 --- a/crates/pstr-core/src/shares.rs +++ b/crates/pstr-core/src/shares.rs @@ -567,6 +567,25 @@ impl ShareStore { Ok(updated) } + /// Call a share something else. Only the name changes: the id, which the + /// catalog, watch state and offline files are keyed by, stays. Sync sees + /// the new name as a change to the share's register, like any other. + pub fn rename(&self, id: &str, name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(Error::Config("a share needs a name".to_owned())); + } + let mut shares = self.list()?; + let share = shares + .iter_mut() + .find(|share| share.id == id) + .ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?; + share.name = name.to_owned(); + let renamed = share.clone(); + write_json(&self.dirs.shares_file(), &shares)?; + Ok(renamed) + } + /// Forget a share and its secrets. /// /// The config entry goes first here, for the mirror-image reason: if the @@ -1257,6 +1276,25 @@ mod tests { ); } + #[test] + fn a_renamed_share_keeps_its_id_and_refuses_an_empty_name() { + let (store, _) = store_in("rename"); + let added = store + .add( + "Library", + "https://drive.proton.me/urls/ABC123#s3cr3t", + None, + ) + .expect("add"); + + let renamed = store.rename(&added.id, " Anime ").expect("rename"); + assert_eq!(renamed.id, added.id); + assert_eq!(renamed.name, "Anime"); + assert_eq!(store.list().expect("list")[0].name, "Anime"); + assert!(store.rename(&added.id, " ").is_err()); + assert!(store.rename("share-missing", "Anime").is_err()); + } + /// A different link is a different share. Repointing this one at it would /// leave every catalog row and offline file describing files that are not /// there. diff --git a/ios/ProtonStream/App/AppModel.swift b/ios/ProtonStream/App/AppModel.swift index 6c32adc..7d52a9f 100644 --- a/ios/ProtonStream/App/AppModel.swift +++ b/ios/ProtonStream/App/AppModel.swift @@ -316,6 +316,18 @@ final class AppModel { } } + /// Call a share something else. The library, history and downloads stay. + func renameShare(id: String, name: String) { + Task { + do { + _ = try await run { try $0.renameShare(shareId: id, name: name) } + } catch { + reportError(error) + } + reload() + } + } + func removeShare(_ id: String) { Task { let name = shares.first { $0.id == id }?.name ?? "the share" diff --git a/ios/ProtonStream/UI/SharesView.swift b/ios/ProtonStream/UI/SharesView.swift index 0f52d5e..6d3585a 100644 --- a/ios/ProtonStream/UI/SharesView.swift +++ b/ios/ProtonStream/UI/SharesView.swift @@ -13,6 +13,7 @@ struct SharesView: View { @State private var browsing = false @State private var repairing: ShareRecord? @State private var removing: ShareRecord? + @State private var renaming: ShareRecord? var body: some View { TabPage("Shares") { @@ -34,6 +35,7 @@ struct SharesView: View { share: share, onRefresh: { model.refreshShare(share.id) }, onRepair: { repairing = share }, + onRename: { renaming = share }, onRemove: { removing = share } ) } @@ -78,6 +80,9 @@ struct SharesView: View { .fullScreenCover(isPresented: $browsing) { DriveBrowser() } + .sheet(item: $renaming) { share in + RenameShareSheet(share: share) { model.renameShare(id: share.id, name: $0) } + } .sheet(item: $repairing) { share in RepairShareSheet(share: share) { url, password in model.repairShare(id: share.id, url: url, password: password) @@ -124,6 +129,7 @@ private struct ShareRow: View { let share: ShareRecord let onRefresh: () -> Void let onRepair: () -> Void + let onRename: () -> Void let onRemove: () -> Void @Environment(\.scheme) private var scheme @@ -147,6 +153,7 @@ private struct ShareRow: View { // One share, not the library: a link that has just had files // added should not cost a walk of every other one. Button(action: onRefresh) { Label("Refresh", systemImage: "arrow.clockwise") } + Button(action: onRename) { Label("Rename", systemImage: "pencil") } // Not a remove: a share whose secret has become unreadable // cannot be removed either. if !share.fromAccount { @@ -165,6 +172,34 @@ private struct ShareRow: View { } } +/// A new name for a share. The library, history and downloads stay. +private struct RenameShareSheet: View { + let share: ShareRecord + let onRename: (String) -> Void + @Environment(\.dismiss) private var dismiss + @State private var name: String + + init(share: ShareRecord, onRename: @escaping (String) -> Void) { + self.share = share + self.onRename = onRename + _name = State(initialValue: share.name) + } + + var body: some View { + DialogSheet("Rename \(share.name)") { + Field(label: "Name", text: $name) + } buttons: { + Button("Cancel") { dismiss() }.buttonStyle(.tonal) + Button("Save") { + onRename(name.trimmingCharacters(in: .whitespaces)) + dismiss() + } + .buttonStyle(.accent) + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } +} + /// Re-enter the link for a share the app can no longer decrypt its secret for. /// /// It has to be the *same* link — a different token is a different share.