Files
proton-stream/ios/ProtonStream/UI/SharesView.swift
T
nvrl 89e55aac12 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.
2026-10-13 15:07:41 +02:00

265 lines
12 KiB
Swift

import SwiftUI
import UIKit
struct SharesView: View {
@Environment(AppModel.self) private var model
@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
@State private var browsing = false
@State private var repairing: ShareRecord?
@State private var removing: ShareRecord?
var body: some View {
TabPage("Shares") {
ZStack(alignment: .bottomTrailing) {
if model.shares.isEmpty && model.account.state == nil {
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
AccountCard(onSignIn: { signingIn = true }, onBrowse: { browsing = true })
if model.shares.isEmpty {
Text("No shares yet. Add a public link, or a folder of your Drive once signed in.")
.textStyle(.bodyMedium)
.foregroundStyle(scheme.onSurfaceVariant)
.padding(16)
}
ForEach(model.shares, id: \.id) { share in
ShareRow(
share: share,
onRefresh: { model.refreshShare(share.id) },
onRepair: { repairing = share },
onRemove: { removing = share }
)
}
}
.padding(.top, 8)
.padding(.bottom, 96)
}
}
// Where a thumb is, and the one thing this page is for.
Button { showAdd = true } label: {
Label("Add share", systemImage: "plus")
.textStyle(.labelLarge)
.padding(.horizontal, 20)
.frame(height: 56)
.foregroundStyle(scheme.onPrimary)
.background(scheme.primary, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.3), radius: 6, y: 3)
}
.buttonStyle(.pressable)
.padding(16)
}
}
.sheet(isPresented: Binding(get: { showAdd || incomingLink != nil }, set: { if !$0 { showAdd = false; incomingLink = nil } })) {
AddShareSheet(initialUrl: incomingLink ?? "") { name, url, password in
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(
get: { signingIn || model.account.state == .secondFactor || model.account.state == .mailboxPassword },
set: { shown in
if shown { return }
signingIn = false
// Swiped away mid sign-in: the half-made session is spent.
if model.account.state == .secondFactor || model.account.state == .mailboxPassword { model.cancelSignIn() }
}
)) {
SignInSheet { signingIn = false }
}
.fullScreenCover(isPresented: $browsing) {
DriveBrowser()
}
.sheet(item: $repairing) { share in
RepairShareSheet(share: share) { url, password in
model.repairShare(id: share.id, url: url, password: password)
}
}
// Asked, because it cannot be undone: the link's secret and its
// offline files go. Watch positions stay (`Catalog::remove_share`).
.alert(
removing.map { "Remove \($0.name)?" } ?? "",
isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
presenting: removing
) { share in
Button("Remove", role: .destructive) { model.removeShare(share.id) }
Button("Cancel", role: .cancel) {}
} message: { _ in
Text("Its titles leave the library and its offline episodes are deleted. Watch progress is kept, so adding the link again picks up where you left off.")
}
}
}
extension ShareRecord: Identifiable {}
/// One share: its name, how it is unlocked, and its actions behind a menu.
/// "120 files · crawled 2 h ago", or that it has not been yet.
func crawlLine(_ share: ShareRecord, now: Int64 = Int64(Date.now.timeIntervalSince1970)) -> String {
guard let at = share.crawledAt else { return "Not crawled yet" }
let files = share.crawlFiles.map { $0 == 1 ? "1 file" : "\($0) files" }
return [files, "crawled \(ago(at, now: now))"].compactMap { $0 }.joined(separator: " · ")
}
/// "just now", "5 min ago", "3 h ago", "2 days ago" — the desktop's `ui::ago`.
func ago(_ at: Int64, now: Int64) -> String {
let seconds = max(now - at, 0)
switch seconds {
case ..<60: return "just now"
case ..<3_600: return "\(seconds / 60) min ago"
case ..<86_400: return "\(seconds / 3_600) h ago"
case ..<172_800: return "1 day ago"
default: return "\(seconds / 86_400) days ago"
}
}
private struct ShareRow: View {
let share: ShareRecord
let onRefresh: () -> Void
let onRepair: () -> Void
let onRemove: () -> Void
@Environment(\.scheme) private var scheme
var body: some View {
HStack(spacing: 16) {
Image(systemName: "folder.badge.person.crop").font(.system(size: 20)).foregroundStyle(scheme.primary).frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(share.name).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface)
Text(share.fromAccount ? "Folder of your Drive"
: share.hasCustomPassword ? "Link and custom password" : "Public link")
.textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
Text(crawlLine(share)).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant)
// Until a crawl succeeds: a link revoked upstream otherwise
// just stops growing.
if let error = share.crawlError {
Text("Last crawl failed: \(error)").textStyle(.bodySmall).foregroundStyle(scheme.error).lineLimit(2)
}
}
Spacer(minLength: 0)
Menu {
// 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") }
// Not a remove: a share whose secret has become unreadable
// cannot be removed either.
if !share.fromAccount {
Button(action: onRepair) { Label("Re-enter link", systemImage: "key") }
}
Button(role: .destructive, action: onRemove) { Label("Remove", systemImage: "trash") }
} label: {
Image(systemName: "ellipsis").font(.system(size: 20)).frame(width: 48, height: 48)
.foregroundStyle(scheme.onSurfaceVariant)
}
.accessibilityLabel("Actions for \(share.name)")
}
.padding(.leading, 16)
.padding(.trailing, 4)
.padding(.vertical, 8)
}
}
/// 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.
/// Rust enforces that; this only says so.
private struct RepairShareSheet: View {
let share: ShareRecord
let onRepair: (String, String?) -> Void
@Environment(\.dismiss) private var dismiss
@State private var url = ""
@State private var password = ""
var body: some View {
DialogSheet("Re-enter link for \(share.name)") {
Text("The stored credentials for this share cannot be read — usually after a device restore or a Keychain reset. Entering the same link again restores access without losing the library or anything downloaded.")
.textStyle(.bodySmall)
Field(label: "Public share URL", text: $url, secret: true)
Field(label: "Custom password (optional)", text: $password, secret: true)
} buttons: {
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
Button("Restore") {
onRepair(url.trimmingCharacters(in: .whitespaces), password)
dismiss()
}
.buttonStyle(.accent)
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty)
}
.secureContent()
}
}
/// Add a link. The form stays up while the link is opened, and with the
/// reason under it when the link is refused, so fixing a typo is a retry
/// rather than starting over.
private struct AddShareSheet: View {
/// Resolves to why the link was refused, or nil once it is added.
let onAdd: (String, String, String?) async -> String?
@Environment(\.dismiss) private var dismiss
@Environment(\.scheme) private var scheme
@State private var name = ""
@State private var url: String
@State private var password = ""
@State private var adding = false
@State private var refusal: String?
init(initialUrl: String, onAdd: @escaping (String, String, String?) async -> String?) {
self.onAdd = onAdd
_url = State(initialValue: initialUrl)
}
private var ready: Bool {
!url.trimmingCharacters(in: .whitespaces).isEmpty
}
var body: some View {
DialogSheet("Add Proton Drive share") {
// The URL fragment is a secret, so the field is a secure one: no
// keyboard learning, no suggestions, and paste still works.
Field(label: "Share link", text: $url, secret: true)
// There is no share sheet into a sideloaded app, so a link copied
// elsewhere comes in from the clipboard.
if url.isEmpty {
Button {
if let text = UIPasteboard.general.string, let link = shareLink(in: text) { url = link }
} label: {
Label("Paste link", systemImage: "doc.on.clipboard")
}
.buttonStyle(.quiet)
}
// Without one, the shared folder's own name.
Field(label: "Name (optional)", text: $name)
Field(label: "Custom password (optional)", text: $password, secret: true)
if adding {
HStack(spacing: 12) {
ProgressView().controlSize(.small).tint(scheme.primary)
Text("Opening the link…")
}
} else if let refusal {
Text(refusal).foregroundStyle(scheme.error)
}
} buttons: {
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
Button(adding ? "Adding…" : "Add") {
adding = true
refusal = nil
Task {
refusal = await onAdd(name.trimmingCharacters(in: .whitespaces), url.trimmingCharacters(in: .whitespaces), password)
adding = false
if refusal == nil { dismiss() }
}
}
.buttonStyle(.accent)
.disabled(!ready || adding)
}
.interactiveDismissDisabled(adding)
.secureContent()
}
}