Files
proton-stream/ios/ProtonStream/UI/SharesView.swift
T
nvrl abb3e3aaf8 feat(core,app,android,ios): rename a share
ShareStore::rename changes only the name; the id that the catalog, watch
state and offline files are keyed by stays the same. Sync carries the
change as it does any other change to the share's register. The desktop
renames in the share's row. The phones rename from the share's menu.
Before, the name given at add time was permanent short of removing the
share.
2026-10-13 16:01:11 +02:00

300 lines
13 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?
@State private var renaming: 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 },
onRename: { renaming = 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: $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)
}
}
// 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 onRename: () -> 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") }
Button(action: onRename) { Label("Rename", systemImage: "pencil") }
// 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)
}
}
/// 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.
/// 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()
}
}