feat(core,app,android,ios): finish adding a first share where it pays off

- A link with a password of its own, added without one, now fails as
  "This link has a password of its own" instead of the handshake's
  refusal. The link's public flags are only asked for after a refusal.
  The desktop form turns its password toggle on.
- After the first share is added, all three clients go to the library.
  While that share is being listed, the empty library says so instead of
  offering to add a share.
- The desktop library offers posters and synopses once there is a
  library to put them on. The offer says what is sent, links to
  Settings, never turns anything on, and "Not now" is remembered.
This commit is contained in:
nvrl committed 2026-10-13 15:07:41 +02:00
1 parent 0f96cc4514
commit 89e55aac12
11 files changed
+150 -10

No files matched your search

@@ -188,6 +188,12 @@ internal fun LibraryScreen(
"Nothing matches",
"No title in the library is called “${state.query.trim()}”.",
)
// A share just added: say it is being looked through, rather
// than offer to add the one that is already filling the page.
state.titles.isEmpty() && state.activity != null -> EmptyState(
"Looking through the share",
"${state.activity} Its titles appear here once it has been listed.",
)
state.titles.isEmpty() -> EmptyState(
"Your library is empty",
"Add a Proton Drive share link, and everything playable behind it shows up here.",
@@ -279,7 +279,14 @@ fun ProtonStreamApp(
}
Destination.Shares -> SharesScreen(
state.shares,
model::addShare,
{ name, url, password ->
// The first share is the library: watch it fill
// rather than stay on a form that has done its job.
val first = state.titles.isEmpty()
model.addShare(name, url, password).also { refusal ->
if (refusal == null && first) destination = Destination.Library
}
},
model::repairShare,
model::refreshShare,
model::removeShare,
+25 -1
View File
@@ -144,6 +144,8 @@ pub enum Action {
PasteShare(String),
/// Go to the add form, caret in the link field.
OpenShareForm,
/// Stop offering posters and synopses on the library page.
DismissMetadataOffer,
/// Store a new link, and password, for a share already in the library.
RepairShare {
id: String,
@@ -813,12 +815,25 @@ impl App {
Event::ShareAdded(name) => {
self.form = ShareForm::default();
self.note(ctx, format!("added {name}"), false);
// The first share is the library: watch it fill rather than
// stay on a form that has done its job. A second one is
// more likely one of several being added in a row.
if self.library.is_empty() && self.page == Page::Shares {
self.page = Page::Library;
}
}
Event::ShareRejected(error) => {
Event::ShareRejected {
error,
needs_password,
} => {
// Nothing was crawled, so nothing will say it finished.
self.crawling = false;
self.form.sending = false;
self.form.error = Some(error);
// The field the refusal asks for, opened and ready.
if needs_password {
self.form.has_password = true;
}
}
Event::Connected { failures } => {
self.connecting = false;
@@ -1645,6 +1660,11 @@ impl App {
self.page = Page::Shares;
self.form.focus = true;
}
Action::DismissMetadataOffer => {
let mut prefs = self.engine.desktop_prefs();
prefs.metadata_offer_dismissed = true;
self.engine.set_desktop_prefs(prefs);
}
Action::AddShare {
name,
url,
@@ -2221,6 +2241,7 @@ impl eframe::App for App {
},
);
let crawl = self.crawling.then(|| crawl_label(&self.crawl_progress));
let App {
engine,
page,
@@ -2320,6 +2341,9 @@ impl eframe::App for App {
loaded: *loaded,
search,
tiles: engine.appearance().tiles,
crawl: crawl.as_deref(),
offer_metadata: !settings.enabled
&& !engine.desktop_prefs().metadata_offer_dismissed,
},
&mut actions,
),
+3
View File
@@ -25,6 +25,8 @@ pub struct DesktopPrefs {
pub filter: crate::app::Filter,
pub sort: pstr_core::browse::Sort,
pub grouped: bool,
/// The library's offer of posters and synopses was turned down.
pub metadata_offer_dismissed: bool,
}
impl Default for DesktopPrefs {
@@ -37,6 +39,7 @@ impl Default for DesktopPrefs {
// On: a share holding a series and its three films is four tiles
// of the same picture otherwise.
grouped: true,
metadata_offer_dismissed: false,
}
}
}
+17 -3
View File
@@ -177,7 +177,11 @@ pub enum Event {
/// The share the form asked for is stored; its crawl is starting.
ShareAdded(String),
/// The share the form asked for was refused, with why. Nothing was crawled.
ShareRejected(String),
ShareRejected {
error: String,
/// The link has a password of its own and none was given.
needs_password: bool,
},
/// Shares opened; playback is possible from here.
Connected {
/// Shares that did not open, with why.
@@ -305,6 +309,16 @@ pub enum Event {
BrowseFailed(String),
}
impl Event {
/// The add form's refusal of `error`.
fn rejected(error: &pstr_core::Error) -> Self {
Self::ShareRejected {
error: error.to_string(),
needs_password: matches!(error, pstr_core::Error::PasswordNeeded),
}
}
}
/// Where the Proton account stands.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum AccountStatus {
@@ -671,7 +685,7 @@ impl Engine {
.await
{
Ok((share, _)) => share,
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
Err(error) => return engine.emit(Event::rejected(&error)),
};
engine.emit(Event::ShareAdded(share.name.clone()));
engine.load_shares();
@@ -720,7 +734,7 @@ impl Engine {
};
let share = match engine.store.add_folder(&name, folder) {
Ok(share) => share,
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
Err(error) => return engine.emit(Event::rejected(&error)),
};
engine.emit(Event::ShareAdded(share.name.clone()));
engine.load_shares();
+58 -2
View File
@@ -19,6 +19,11 @@ pub struct Shelves<'a> {
pub search: &'a str,
/// The grid's shape.
pub tiles: Tiles,
/// What a crawl in progress has found, if one is.
pub crawl: Option<&'a str>,
/// Whether to offer posters and synopses: they are off, and the offer has
/// not been turned down.
pub offer_metadata: bool,
}
pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec<Action>) {
@@ -28,12 +33,14 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
loaded,
search,
tiles,
crawl,
offer_metadata,
} = shelves;
if !loaded {
return placeholder(ui);
}
if library.is_empty() {
return empty_state(ui, actions);
return empty_state(ui, crawl, actions);
}
let pick = |indices: &[usize]| -> Vec<&Title> {
@@ -48,6 +55,10 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if !searching && offer_metadata {
metadata_offer(ui, actions);
ui.add_space(theme::space::XL);
}
if !searching {
if let Some(title) = featured(library, view, art.metadata, today()) {
hero(ui, art, title, actions);
@@ -635,9 +646,54 @@ fn placeholder(ui: &mut egui::Ui) {
}
}
fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
/// Posters and synopses, offered once there is a library to put them on and
/// never turned on unasked: switching them on sends title names out.
fn metadata_offer(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::symmetric(14, 12))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(egui::RichText::new("Add posters and synopses?").strong());
ui.label(ui::muted(
"Turning it on in Settings sends the titles in your library — not your \
files, and nothing about what you have watched — to AniList or TMDB.",
));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Not now").clicked() {
actions.push(Action::DismissMetadataOffer);
}
if ui::accent_button(ui, "Open settings").clicked() {
actions.push(Action::Goto(Page::Settings));
}
});
});
});
}
fn empty_state(ui: &mut egui::Ui, crawl: Option<&str>, actions: &mut Vec<Action>) {
ui.vertical_centered(|ui| {
ui.add_space(120.0);
// A share just added: say it is being looked through, rather than
// offer to add the one that is already filling the page.
if let Some(crawl) = crawl {
ui.add(egui::Spinner::new().size(28.0));
ui.add_space(theme::space::L);
ui.label(
theme::Role::Title
.rich("Looking through the share")
.strong(),
);
ui.add_space(theme::space::S);
ui.label(ui::muted(format!(
"Its titles appear here once it has been listed ({crawl})."
)));
return;
}
ui.label(
theme::Role::Title
.rich("Nothing in the library yet")
+5
View File
@@ -46,6 +46,11 @@ pub enum Error {
/// mailbox password.
#[error("{0}")]
SignIn(String),
/// A public link with a password of its own was opened without one. The
/// handshake refusal says nothing a viewer can act on; this does.
#[error("This link has a password of its own. Enter it to add the link.")]
PasswordNeeded,
}
impl Error {
+16 -1
View File
@@ -443,7 +443,22 @@ impl ShareStore {
));
}
let custom_password = custom_password.filter(|password| !password.is_empty());
let client = open_link(url, custom_password).await?;
let client = match open_link(url, custom_password).await {
Ok(client) => client,
// Asked only after a refusal: the link's public flags cost a round
// trip, and most links open without one.
Err(error) if custom_password.is_none() => {
let protected = ProtonDrivePublicLinkClient::info(client_configuration(), url)
.await
.is_ok_and(|info| info.is_custom_password_protected);
return Err(if protected {
Error::PasswordNeeded
} else {
error
});
}
Err(error) => return Err(error),
};
// A name is the viewer's to give, not theirs to invent before they
// can see anything: without one, the shared folder's own name.
let name = match name.trim() {
+3 -1
View File
@@ -181,7 +181,9 @@ struct RootView: View {
selectedTitleKey = title.key
})
case .shares:
SharesView(incomingLink: $incomingLink)
// The first share is the library: watch it fill rather than stay
// on a form that has done its job.
SharesView(incomingLink: $incomingLink, onFirstShareAdded: { select(.library) })
case .downloads:
DownloadsView()
case .settings:
+4
View File
@@ -145,6 +145,10 @@ struct LibraryView: View {
LibrarySkeleton()
} else if model.titles.isEmpty && !model.query.trimmingCharacters(in: .whitespaces).isEmpty {
EmptyState("Nothing matches", "No title in the library is called “\(model.query.trimmingCharacters(in: .whitespaces))”.")
} else if model.titles.isEmpty, let activity = model.activity {
// A share just added: say it is being looked through, rather than
// offer to add the one that is already filling the page.
EmptyState("Looking through the share", "\(activity) Its titles appear here once it has been listed.")
} else if model.titles.isEmpty {
ScrollView {
EmptyState(
+5 -1
View File
@@ -6,6 +6,7 @@ struct SharesView: View {
@Environment(\.scheme) private var scheme
/// A link opened in this app: the Add form opens with it filled in.
@Binding var incomingLink: String?
var onFirstShareAdded: () -> Void = {}
@State private var showAdd = false
@State private var signingIn = false
@@ -57,7 +58,10 @@ struct SharesView: View {
}
.sheet(isPresented: Binding(get: { showAdd || incomingLink != nil }, set: { if !$0 { showAdd = false; incomingLink = nil } })) {
AddShareSheet(initialUrl: incomingLink ?? "") { name, url, password in
await model.addShare(name: name, url: url, password: password)
let first = model.titles.isEmpty
let refusal = await model.addShare(name: name, url: url, password: password)
if refusal == nil && first { onFirstShareAdded() }
return refusal
}
}
.sheet(isPresented: Binding(