Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ public struct WireMeetingsFactory {
let fetchMeetingLink: @Sendable (QualifiedID) async throws -> MeetingLink? = {
try await meetingRepository.fetchMeetingLink(id: $0)
}
let refreshMeetingLink: @Sendable (QualifiedID, String?) async throws -> MeetingLink? = {
try await meetingRepository.refreshMeetingLink(id: $0, password: $1)
}
let meetingsViewModel = AllMeetingsViewModel(
currentDateProvider: currentDateProvider,
upcomingMeetingsUseCase: fetchUpcomingMeetingsUseCase,
Expand All @@ -92,6 +95,7 @@ public struct WireMeetingsFactory {
observeAttendedMeetingsUseCase: observeAttendedMeetingsUseCase,
joinMeetingCallUseCase: joinMeetingCallUseCase,
fetchMeetingLink: supportsMeetingLinks ? fetchMeetingLink : nil,
refreshMeetingLink: supportsMeetingLinks ? refreshMeetingLink : nil,
onInstantMeetingCreated: onInstantMeetingCreated,
makeFormViewModel: { mode, onSuccess in
MeetingFormViewModel(
Expand All @@ -101,6 +105,8 @@ public struct WireMeetingsFactory {
updateMeetingUseCase: updateMeetingUseCase,
currentDateProvider: currentDateProvider,
supportsMeetingLinks: supportsMeetingLinks,
fetchMeetingLink: supportsMeetingLinks ? fetchMeetingLink : nil,
refreshMeetingLink: supportsMeetingLinks ? refreshMeetingLink : nil,
onSuccess: onSuccess
)
}
Expand Down
6 changes: 6 additions & 0 deletions WireCalling/Sources/WireCallingData/MeetingRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ public final class MeetingRepository: MeetingRepositoryProtocol {
return MeetingLink(url: url, hasPassword: hasPassword)
}

public func refreshMeetingLink(id: QualifiedID, password: String?) async throws -> MeetingLink? {
let response = try await meetingsAPI.refreshMeetingLink(id: id, password: password)
guard let url = response.link, let hasPassword = response.hasPassword else { return nil }
return MeetingLink(url: url, hasPassword: hasPassword)
}

public func pullMeetings() async throws {
let responses: [MeetingResponse]
do {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ public protocol MeetingRepositoryProtocol: Sendable {

func fetchMeetingLink(id: QualifiedID) async throws -> MeetingLink?

func refreshMeetingLink(id: QualifiedID, password: String?) async throws -> MeetingLink?

/// Pulls all meetings from the server, replacing the locally stored
/// meetings, e.g. as part of a sync.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@
"wireMeetings.link.confirmation.instantTitle" = "Invite guests via link";
"wireMeetings.link.confirmation.close" = "Close";
"wireMeetings.link.copied" = "Link copied to clipboard";
"wireMeetings.link.generate" = "Generate new link";
"wireMeetings.link.consequence" = "Any previous link to this meetings will be revoked and a new link will be created.";
"wireMeetings.link.creating" = "Creating...";
"wireMeetings.link.created" = "New link created. Previous links no longer work.";
"wireMeetings.link.close" = "Close";
"wireMeetings.link.createError" = "Could not create the meeting link. Please try again.";
"wireMeetings.link.unavailable" = "The meeting link is unavailable.";
"wireMeetings.link.loadError" = "Could not load the meeting link. Please try again.";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,9 @@ struct MeetingFormView: View {

@Environment(\.dismiss) private var dismiss
@Environment(\.wireAccentColor) private var wireAccentColor
@Environment(\.isClipboardEnabled) private var isClipboardEnabled
@State private(set) var viewModel: MeetingFormViewModel
@State private var expandedField: ExpandedField?
@State private var isPresentingMemberSelection = false
@State private var isPasswordVisible = false
@State private var isPasswordCopied = false
@ScaledMetric private var passwordFieldHeight: CGFloat = 44
@FocusState private var isTitleFieldFocused: Bool

var body: some View {
Expand All @@ -51,6 +47,13 @@ struct MeetingFormView: View {
if viewModel.canSetPassword {
passwordSection
}
if viewModel.canChangeMeetingLink {
Section {
Button(L10n.Localizable.WireMeetings.Link.title, action: viewModel.meetingLinkTapped)
.accessibilityIdentifier(Locators.WireMeetings.MeetingRow.meetingLink)
.disabled(viewModel.isLoading)
}
}
}
.listSectionSpacing(.compact)
.onAppear {
Expand Down Expand Up @@ -93,6 +96,9 @@ struct MeetingFormView: View {
.sheet(isPresented: $isPresentingMemberSelection) {
MemberSelectionView(viewModel: viewModel.makeMemberSelectionViewModel())
}
.sheet(item: $viewModel.presentedLink) { model in
MeetingLinkView(viewModel: model)
}
.alert(viewModel.errorTitle, isPresented: $viewModel.hasError) {
Button(Strings.Error.Alert.ok) {
viewModel.acknowledgeError { dismiss() }
Expand Down Expand Up @@ -219,103 +225,15 @@ struct MeetingFormView: View {
}

private var passwordSection: some View {
Section {
passwordField(
$viewModel.password,
placeholder: Strings.Password.placeholder,
isValid: viewModel.isPasswordValid,
locator: .password
)
passwordField(
$viewModel.confirmPassword,
placeholder: Strings.Password.confirm,
isValid: viewModel.confirmPassword.isEmpty || viewModel.passwordsMatch,
locator: .confirmPassword
)
Button(Strings.Password.generate, action: viewModel.generatePassword)
.accessibilityIdentifier(Locators.WireMeetings.MeetingForm.generatePassword)
Button(isPasswordCopied ? Strings.Password.copied : Strings.Password.copy) {
viewModel.copyPassword()
isPasswordCopied = true
}
.disabled(viewModel.password.isEmpty || !isClipboardEnabled || !viewModel.isPasswordValid)
.accessibilityIdentifier(Locators.WireMeetings.MeetingForm.copyPassword)
.task(id: isPasswordCopied) {
guard isPasswordCopied else { return }
do {
try await Task.sleep(for: .seconds(3))
isPasswordCopied = false
} catch {}
}
.onChange(of: Array(viewModel.password.unicodeScalars)) {
isPasswordCopied = false
}
} header: {
Text(Strings.Password.title)
} footer: {
VStack(alignment: .leading) {
if !viewModel.confirmPassword.isEmpty, !viewModel.passwordsMatch {
Text(Strings.Password.mismatch)
.foregroundStyle(ColorTheme.Base.error.color)
}
Text(Strings.Password.requirements(MeetingFormViewModel.minimumPasswordLength))
.foregroundStyle(viewModel.isPasswordValid ? ColorTheme.Base.secondaryText.color : ColorTheme.Base
.error.color)
.accessibilityIdentifier(Locators.WireMeetings.MeetingForm.passwordRequirements)
}
}
.disabled(viewModel.isLoading)
.textCase(nil)
}

private func passwordField(
_ text: Binding<String>,
placeholder: String,
isValid: Bool,
locator: Locators.WireMeetings.MeetingForm
) -> some View {
HStack {
ContextMenuControllableTextField(
text: text,
placeholder: placeholder,
isSecureTextEntry: !isPasswordVisible,
isContextMenuAllowed: isClipboardEnabled,
textContentType: .newPassword,
textColor: UIColor(isValid ? ColorTheme.Backgrounds.onSurface.color : ColorTheme.Base.error.color)
)
.id(isPasswordVisible)
.frame(height: passwordFieldHeight)
.accessibilityLabel(locator == .password ? Strings.Password.title : placeholder)
.accessibilityHint(locator == .password ?
Strings.Password.requirements(MeetingFormViewModel.minimumPasswordLength) : "")
.accessibilityIdentifier(locator)
if !text.wrappedValue.isEmpty {
Button {
text.wrappedValue = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color(.lightGray))
.frame(width: 44, height: 44)
.contentShape(.rect)
}
.buttonStyle(.borderless)
.accessibilityLabel(locator == .password ? Strings.Password.clear : Strings.Password.clearConfirmation)
.accessibilityIdentifier(locator == .password ? Locators.WireMeetings.MeetingForm.clearPassword :
Locators.WireMeetings.MeetingForm.clearConfirmPassword)
}
Button {
isPasswordVisible.toggle()
} label: {
Label(
isPasswordVisible ? Strings.Password.hide : Strings.Password.show,
systemImage: isPasswordVisible ? "eye.slash" : "eye"
)
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.accessibilityIdentifier(locator == .password ? Locators.WireMeetings.MeetingForm.passwordVisibility :
Locators.WireMeetings.MeetingForm.confirmPasswordVisibility)
}
MeetingPasswordSection(
password: $viewModel.password,
confirmPassword: $viewModel.confirmPassword,
isPasswordValid: viewModel.isPasswordValid,
passwordsMatch: viewModel.passwordsMatch,
isDisabled: viewModel.isLoading,
generatePassword: viewModel.generatePassword,
copyPassword: { viewModel.copyPassword() }
)
}

private var participantsSection: some View {
Expand Down Expand Up @@ -676,3 +594,121 @@ private extension MeetingMember {
)
}
}

struct MeetingPasswordSection: View {
private typealias Strings = L10n.Localizable.WireMeetings.Schedule

@Environment(\.isClipboardEnabled) private var isClipboardEnabled
@Binding var password: String
@Binding var confirmPassword: String
let isPasswordValid: Bool
let passwordsMatch: Bool
let isDisabled: Bool
let generatePassword: () -> Void
let copyPassword: () -> Void

@State private var isPasswordVisible = false
@State private var isPasswordCopied = false
@ScaledMetric private var passwordFieldHeight: CGFloat = 44

var body: some View {
Section {
passwordField(
$password,
placeholder: Strings.Password.placeholder,
isValid: isPasswordValid,
locator: .password
)
passwordField(
$confirmPassword,
placeholder: Strings.Password.confirm,
isValid: confirmPassword.isEmpty || passwordsMatch,
locator: .confirmPassword
)
Button(Strings.Password.generate, action: generatePassword)
.accessibilityIdentifier(Locators.WireMeetings.MeetingForm.generatePassword)
Button(isPasswordCopied ? Strings.Password.copied : Strings.Password.copy) {
copyPassword()
isPasswordCopied = true
}
.disabled(password.isEmpty || !isClipboardEnabled || !isPasswordValid)
.accessibilityIdentifier(Locators.WireMeetings.MeetingForm.copyPassword)
.task(id: isPasswordCopied) {
guard isPasswordCopied else { return }
do {
try await Task.sleep(for: .seconds(3))
isPasswordCopied = false
} catch {}
}
.onChange(of: Array(password.unicodeScalars)) {
isPasswordCopied = false
}
} header: {
Text(Strings.Password.title)
} footer: {
VStack(alignment: .leading) {
if !confirmPassword.isEmpty, !passwordsMatch {
Text(Strings.Password.mismatch)
.foregroundStyle(ColorTheme.Base.error.color)
}
Text(Strings.Password.requirements(MeetingFormViewModel.minimumPasswordLength))
.foregroundStyle(isPasswordValid ? ColorTheme.Base.secondaryText.color : ColorTheme.Base
.error.color)
.accessibilityIdentifier(Locators.WireMeetings.MeetingForm.passwordRequirements)
}
}
.disabled(isDisabled)
.textCase(nil)
}

private func passwordField(
_ text: Binding<String>,
placeholder: String,
isValid: Bool,
locator: Locators.WireMeetings.MeetingForm
) -> some View {
HStack {
ContextMenuControllableTextField(
text: text,
placeholder: placeholder,
isSecureTextEntry: !isPasswordVisible,
isContextMenuAllowed: isClipboardEnabled,
textContentType: .newPassword,
textColor: UIColor(isValid ? ColorTheme.Backgrounds.onSurface.color : ColorTheme.Base.error.color)
)
.id(isPasswordVisible)
.frame(height: passwordFieldHeight)
.accessibilityLabel(locator == .password ? Strings.Password.title : placeholder)
.accessibilityHint(locator == .password ?
Strings.Password.requirements(MeetingFormViewModel.minimumPasswordLength) : "")
.accessibilityIdentifier(locator)
if !text.wrappedValue.isEmpty {
Button {
text.wrappedValue = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(ColorTheme.Base.secondaryText.color)
.frame(width: 44, height: 44)
.contentShape(.rect)
}
.buttonStyle(.borderless)
.accessibilityLabel(locator == .password ? Strings.Password.clear : Strings.Password.clearConfirmation)
.accessibilityIdentifier(locator == .password ? Locators.WireMeetings.MeetingForm.clearPassword :
Locators.WireMeetings.MeetingForm.clearConfirmPassword)
}
Button {
isPasswordVisible.toggle()
} label: {
Label(
isPasswordVisible ? Strings.Password.hide : Strings.Password.show,
systemImage: isPasswordVisible ? "eye.slash" : "eye"
)
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.accessibilityIdentifier(locator == .password ? Locators.WireMeetings.MeetingForm.passwordVisibility :
Locators.WireMeetings.MeetingForm.confirmPasswordVisibility)
}
}

}
Loading
Loading