updated ui for macos and ios
This commit is contained in:
1 parent
df7ab24677
commit
e3fcee3351
10 files changed
+619
-576
No files matched your search
@@ -440,7 +440,7 @@
|
||||
E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInformation.swift; sourceTree = "<group>"; };
|
||||
E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRoomStore.swift; sourceTree = "<group>"; };
|
||||
EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyArchiveImporter.swift; sourceTree = "<group>"; };
|
||||
EA54340DD785335237158BEF /* LumaMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; name = LumaMac.app; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA54340DD785335237158BEF /* Luma.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F0A35C9A7B52458996513382 /* WatchVoiceRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchVoiceRecorder.swift; sourceTree = "<group>"; };
|
||||
F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionBanner.swift; sourceTree = "<group>"; };
|
||||
F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = "<group>"; };
|
||||
@@ -714,7 +714,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DF8C531E464C9C4EDE2648C6 /* Luma.app */,
|
||||
EA54340DD785335237158BEF /* LumaMac.app */,
|
||||
EA54340DD785335237158BEF /* Luma.app */,
|
||||
6317FAA29F22A3EA8450208F /* LumaTests.xctest */,
|
||||
96720381802141EBA1DA9772 /* LumaUITests.xctest */,
|
||||
19D4E9404712E8643E31CB93 /* LumaWatch.app */,
|
||||
@@ -807,7 +807,7 @@
|
||||
2A745A82DD6F34F0CB46B52B /* WebRTC */,
|
||||
);
|
||||
productName = LumaMac;
|
||||
productReference = EA54340DD785335237158BEF /* LumaMac.app */;
|
||||
productReference = EA54340DD785335237158BEF /* Luma.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
A086B6D25E7969CB643F1C8A /* LumaUITests */ = {
|
||||
@@ -1255,7 +1255,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat.macos;
|
||||
PRODUCT_NAME = Luma;
|
||||
SDKROOT = macosx;
|
||||
@@ -1277,7 +1277,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat.macos;
|
||||
PRODUCT_NAME = Luma;
|
||||
SDKROOT = macosx;
|
||||
|
||||
@@ -33,9 +33,9 @@ struct AppLockView: View {
|
||||
SecureField("Пароль", text: $passcode)
|
||||
.focused($isFocused)
|
||||
.textContentType(.password)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -57,36 +57,44 @@ struct AppLockView: View {
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(passcode.isEmpty)
|
||||
|
||||
if model.biometricUnlockAvailable, model.appLockBiometricIsEnabled {
|
||||
if model.biometricUnlockAvailable,
|
||||
model.appLockBiometricIsEnabled
|
||||
{
|
||||
Button {
|
||||
Task { await biometricUnlock() }
|
||||
} label: {
|
||||
Label(
|
||||
"Войти по \(model.biometricUnlockName)",
|
||||
systemImage: model.biometricUnlockName == "Face ID" ? "faceid" : "touchid"
|
||||
systemImage: model.biometricUnlockName == "Face ID"
|
||||
? "faceid" : "touchid"
|
||||
)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(28)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
.background(
|
||||
.regularMaterial,
|
||||
in: RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||
)
|
||||
.padding(24)
|
||||
}
|
||||
.onAppear {
|
||||
isFocused = true
|
||||
}
|
||||
#if os(iOS)
|
||||
.task {
|
||||
// Auto-prompt biometrics once when the lock screen appears. iOS
|
||||
// only: on macOS the system Touch ID dialog blocks the window
|
||||
// and freezes the lock screen.
|
||||
guard model.biometricUnlockAvailable, model.appLockBiometricIsEnabled else {
|
||||
return
|
||||
#if os(iOS)
|
||||
.task {
|
||||
// Auto-prompt biometrics once when the lock screen appears. iOS
|
||||
// only: on macOS the system Touch ID dialog blocks the window
|
||||
// and freezes the lock screen.
|
||||
guard model.biometricUnlockAvailable,
|
||||
model.appLockBiometricIsEnabled
|
||||
else {
|
||||
return
|
||||
}
|
||||
await biometricUnlock()
|
||||
}
|
||||
await biometricUnlock()
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
@@ -139,30 +147,26 @@ struct AppLockPasscodeSheet: View {
|
||||
@State private var errorText: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
if needsOldPasscode {
|
||||
Section("Текущий пароль") {
|
||||
SecureField("Текущий пароль", text: $oldPasscode)
|
||||
.textContentType(.password)
|
||||
}
|
||||
#if os(iOS)
|
||||
NavigationStack {
|
||||
Form {
|
||||
formcontent()
|
||||
}
|
||||
Section(title) {
|
||||
SecureField(fieldLabel, text: $passcode)
|
||||
.textContentType(.password)
|
||||
if needsRepeatedPasscode {
|
||||
SecureField("Повторите пароль", text: $repeatedPasscode)
|
||||
.textContentType(.password)
|
||||
.navigationTitle(navigationTitle)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Отмена") { dismiss() }
|
||||
}
|
||||
}
|
||||
if let errorText {
|
||||
Section {
|
||||
Text(errorText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Готово", action: submit)
|
||||
.disabled(!canSubmit)
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
List {
|
||||
formcontent()
|
||||
}
|
||||
.navigationTitle(navigationTitle)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
@@ -173,27 +177,43 @@ struct AppLockPasscodeSheet: View {
|
||||
.disabled(!canSubmit)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 420, minHeight: 320)
|
||||
#endif
|
||||
.frame(minWidth: 420, minHeight: 320)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var needsOldPasscode: Bool {
|
||||
@ViewBuilder
|
||||
private func formcontent() -> some View {
|
||||
switch mode {
|
||||
case .change, .verify:
|
||||
return true
|
||||
case .setup:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var needsRepeatedPasscode: Bool {
|
||||
switch mode {
|
||||
case .setup, .change:
|
||||
return true
|
||||
Section(title) {
|
||||
SecureField(fieldLabel, text: $passcode)
|
||||
.textContentType(.password)
|
||||
SecureField("Повторите пароль", text: $repeatedPasscode)
|
||||
.textContentType(.password)
|
||||
}
|
||||
case .verify:
|
||||
return false
|
||||
Section("Текущий пароль") {
|
||||
SecureField("Текущий пароль", text: $oldPasscode)
|
||||
.textContentType(.password)
|
||||
}
|
||||
case .change:
|
||||
Section("Текущий пароль") {
|
||||
SecureField("Текущий пароль", text: $oldPasscode)
|
||||
.textContentType(.password)
|
||||
}
|
||||
Section(title) {
|
||||
SecureField(fieldLabel, text: $passcode)
|
||||
.textContentType(.password)
|
||||
SecureField("Повторите пароль", text: $repeatedPasscode)
|
||||
.textContentType(.password)
|
||||
}
|
||||
}
|
||||
if let errorText {
|
||||
Section {
|
||||
Text(errorText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,14 +232,16 @@ struct AppLockPasscodeSheet: View {
|
||||
case .verify(let action):
|
||||
switch action {
|
||||
case .disableLock: return "Выключить блокировку"
|
||||
case .enableBiometrics, .disableBiometrics: return "Подтвердите пароль"
|
||||
case .enableBiometrics, .disableBiometrics:
|
||||
return "Подтвердите пароль"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var fieldLabel: String {
|
||||
switch mode {
|
||||
case .setup, .change: return "Пароль (минимум \(AppLockPolicy.minimumLength) символа)"
|
||||
case .setup, .change:
|
||||
return "Пароль (минимум \(AppLockPolicy.minimumLength) символа)"
|
||||
case .verify: return "Пароль"
|
||||
}
|
||||
}
|
||||
@@ -229,7 +251,8 @@ struct AppLockPasscodeSheet: View {
|
||||
case .setup:
|
||||
return !passcode.isEmpty && !repeatedPasscode.isEmpty
|
||||
case .change:
|
||||
return !oldPasscode.isEmpty && !passcode.isEmpty && !repeatedPasscode.isEmpty
|
||||
return !oldPasscode.isEmpty && !passcode.isEmpty
|
||||
&& !repeatedPasscode.isEmpty
|
||||
case .verify:
|
||||
return !oldPasscode.isEmpty
|
||||
}
|
||||
@@ -240,7 +263,8 @@ struct AppLockPasscodeSheet: View {
|
||||
switch mode {
|
||||
case .setup:
|
||||
guard AppLockPolicy.isValid(passcode) else {
|
||||
errorText = "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа."
|
||||
errorText =
|
||||
"Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа."
|
||||
return
|
||||
}
|
||||
guard passcode == repeatedPasscode else {
|
||||
@@ -251,11 +275,13 @@ struct AppLockPasscodeSheet: View {
|
||||
if model.enableAppLock(passcode: passcode) {
|
||||
dismiss()
|
||||
} else {
|
||||
errorText = model.errorMessage ?? "Не удалось включить блокировку."
|
||||
errorText =
|
||||
model.errorMessage ?? "Не удалось включить блокировку."
|
||||
}
|
||||
case .change:
|
||||
guard AppLockPolicy.isValid(passcode) else {
|
||||
errorText = "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа."
|
||||
errorText =
|
||||
"Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа."
|
||||
return
|
||||
}
|
||||
guard passcode == repeatedPasscode else {
|
||||
@@ -279,14 +305,16 @@ struct AppLockPasscodeSheet: View {
|
||||
oldPasscode = ""
|
||||
}
|
||||
case .enableBiometrics:
|
||||
if model.setAppLockBiometricUnlock(true, passcode: oldPasscode) {
|
||||
if model.setAppLockBiometricUnlock(true, passcode: oldPasscode)
|
||||
{
|
||||
dismiss()
|
||||
} else {
|
||||
errorText = "Неверный пароль."
|
||||
oldPasscode = ""
|
||||
}
|
||||
case .disableBiometrics:
|
||||
if model.setAppLockBiometricUnlock(false, passcode: oldPasscode) {
|
||||
if model.setAppLockBiometricUnlock(false, passcode: oldPasscode)
|
||||
{
|
||||
dismiss()
|
||||
} else {
|
||||
errorText = "Неверный пароль."
|
||||
@@ -296,4 +324,3 @@ struct AppLockPasscodeSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,7 +814,7 @@ struct ChatView: View {
|
||||
} else if let replyingToMessage {
|
||||
replyBanner(replyingToMessage)
|
||||
}
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
TextField(
|
||||
editingMessageID == nil
|
||||
? "Сообщение" : "Изменить сообщение",
|
||||
@@ -843,26 +843,7 @@ struct ChatView: View {
|
||||
.padding(.trailing, 12)
|
||||
.padding(.vertical, 9)
|
||||
.frame(minHeight: 42)
|
||||
.background(
|
||||
.ultraThinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: 21, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 21, style: .continuous)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.3),
|
||||
Color.secondary.opacity(0.18),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.75
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.075), radius: 8, y: 2)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
|
||||
.glassEffect(in: .rect(cornerRadius: 21))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -875,21 +856,14 @@ struct ChatView: View {
|
||||
canUseComposer ? Color.accentColor : Color.secondary
|
||||
)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(
|
||||
Color.secondary.opacity(0.2),
|
||||
lineWidth: 0.75
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.075), radius: 8, y: 2)
|
||||
// .background(.ultraThinMaterial, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canUseComposer)
|
||||
.accessibilityLabel(
|
||||
editingMessageID == nil ? "Отправить" : "Сохранить исправление"
|
||||
)
|
||||
.glassEffect()
|
||||
} else {
|
||||
TelegramRecordButton(
|
||||
mode: .voice,
|
||||
@@ -968,15 +942,6 @@ struct ChatView: View {
|
||||
}
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(
|
||||
Color.secondary.opacity(0.18),
|
||||
lineWidth: 0.75
|
||||
)
|
||||
)
|
||||
if model.isSendingAttachment || isPreparingAttachments {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
@@ -987,8 +952,8 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
.frame(width: 42, height: 42)
|
||||
.shadow(color: .black.opacity(0.075), radius: 8, y: 2)
|
||||
.contentShape(Circle())
|
||||
.glassEffect()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Добавить вложение")
|
||||
@@ -1166,14 +1131,7 @@ struct ChatView: View {
|
||||
}
|
||||
.padding(.horizontal, 13)
|
||||
.frame(minHeight: 42)
|
||||
.background(
|
||||
.ultraThinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: 21, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 21, style: .continuous)
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 0.75)
|
||||
)
|
||||
.glassEffect()
|
||||
}
|
||||
|
||||
private var recordingLockIndicator: some View {
|
||||
@@ -1185,10 +1143,7 @@ struct ChatView: View {
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.frame(width: 38, height: 55)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.overlay(
|
||||
Capsule().stroke(Color.secondary.opacity(0.22), lineWidth: 0.75)
|
||||
)
|
||||
.glassEffect(in: Capsule())
|
||||
.accessibilityLabel("Проведите вверх, чтобы зафиксировать запись")
|
||||
}
|
||||
|
||||
@@ -1799,8 +1754,6 @@ struct ChatView: View {
|
||||
Capsule()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 3, height: 34)
|
||||
// Image(systemName: "pencil")
|
||||
// .foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Редактирование")
|
||||
.font(.caption.weight(.semibold))
|
||||
@@ -1818,8 +1771,6 @@ struct ChatView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Отменить редактирование")
|
||||
}
|
||||
// .padding(.horizontal, 14)
|
||||
// .padding(.top, 8)
|
||||
}
|
||||
|
||||
/// Telegram-style reply plate: a rounded card above the composer with
|
||||
@@ -1831,9 +1782,6 @@ struct ChatView: View {
|
||||
.frame(width: 3, height: 34)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 7) {
|
||||
// Image(systemName: "arrowshape.turn.up.left.fill")
|
||||
// .font(.caption.weight(.semibold))
|
||||
// .foregroundStyle(.tint)
|
||||
Text(
|
||||
"Ответ: \(message.senderDisplayName ?? model.displayName(for: message.senderJID))"
|
||||
)
|
||||
@@ -1856,17 +1804,6 @@ struct ChatView: View {
|
||||
.accessibilityLabel("Отменить ответ")
|
||||
}
|
||||
.accessibilityIdentifier("reply-banner")
|
||||
// .padding(.horizontal, 12)
|
||||
// .padding(.vertical, 8)
|
||||
// .background(
|
||||
// .thinMaterial,
|
||||
// in: RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
// )
|
||||
// .overlay {
|
||||
// RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
// .stroke(Color.secondary.opacity(0.16), lineWidth: 0.75)
|
||||
// }
|
||||
// .padding(.horizontal, 10)
|
||||
}
|
||||
|
||||
private func perform(_ action: MessageDestructiveAction) {
|
||||
@@ -1908,15 +1845,10 @@ struct ChatView: View {
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.frame(width: 46, height: 46)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
.overlay {
|
||||
Circle()
|
||||
.stroke(Color.accentColor.opacity(0.42), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.16), radius: 10, y: 4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Circle())
|
||||
.glassEffect()
|
||||
.accessibilityLabel("Перейти к последнему сообщению")
|
||||
.help("Перейти в конец истории")
|
||||
}
|
||||
@@ -2211,24 +2143,6 @@ private struct TelegramRecordButton: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
Circle()
|
||||
.fill(
|
||||
isRecording ? Color.red.opacity(0.13) : Color.clear
|
||||
)
|
||||
}
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(
|
||||
isRecording
|
||||
? Color.red.opacity(0.42)
|
||||
: Color.secondary.opacity(0.2),
|
||||
lineWidth: 0.75
|
||||
)
|
||||
)
|
||||
|
||||
if isFinalizing {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
@@ -2242,13 +2156,8 @@ private struct TelegramRecordButton: View {
|
||||
}
|
||||
.frame(width: 42, height: 42)
|
||||
.scaleEffect(isRecording && !isLocked ? 1.34 : (isPressing ? 1.08 : 1))
|
||||
.shadow(
|
||||
color: isRecording
|
||||
? Color.black.opacity(0.2) : Color.black.opacity(0.075),
|
||||
radius: 8,
|
||||
y: isRecording ? 4 : 2
|
||||
)
|
||||
.contentShape(Circle())
|
||||
.glassEffect()
|
||||
#if os(iOS)
|
||||
.overlay {
|
||||
ComposerRecordTouchSurface(
|
||||
|
||||
@@ -9,7 +9,6 @@ struct ConnectionBanner: View {
|
||||
case .connected:
|
||||
if model.isArchiveSyncing || !model.isOMEMOReady {
|
||||
banner(
|
||||
// text: model.isArchiveSyncing ? "Синхронизация истории…" : "Публикация ключей OMEMO…",
|
||||
text: "Обновление",
|
||||
color: .clear,
|
||||
progress: true
|
||||
@@ -19,27 +18,9 @@ struct ConnectionBanner: View {
|
||||
.font(.headline)
|
||||
}
|
||||
case .connecting:
|
||||
// banner(icon: "antenna.radiowaves.left.and.right", text: "Подключение…", color: .clear, progress: true)
|
||||
banner(text: "Подключение", color: .clear, progress: true)
|
||||
case .disconnected:
|
||||
banner(text: "Ожидание сети", color: .clear, progress: true)
|
||||
// HStack(spacing: 8) {
|
||||
// Image(systemName: "wifi.exclamationmark")
|
||||
// Text(reason ?? "Нет соединения")
|
||||
// .lineLimit(1)
|
||||
// Spacer()
|
||||
// Button { Task { await model.reconnect() } } label: {
|
||||
// Label("Обновить", systemImage: "arrow.clockwise")
|
||||
// }
|
||||
// .buttonStyle(.borderless)
|
||||
// .fontWeight(.semibold)
|
||||
// }
|
||||
// .font(.caption)
|
||||
// .foregroundStyle(.white)
|
||||
// .padding(.horizontal, 12)
|
||||
// .padding(.vertical, 8)
|
||||
//// .background(Color.red.opacity(0.88))
|
||||
// .background(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,13 +30,9 @@ struct ConnectionBanner: View {
|
||||
ProgressView().controlSize(.mini).tint(.white)
|
||||
}
|
||||
Text(text)
|
||||
// Spacer()
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white)
|
||||
// .padding(.horizontal, 12)
|
||||
// .padding(.vertical, 8)
|
||||
// .background(color.opacity(0.88))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ struct LoginView: View {
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Toggle("Direct TLS", isOn: $directTLS)
|
||||
.toggleStyle(.switch)
|
||||
Text(
|
||||
"Без ручного адреса Luma использует DNS SRV и автоматически выбирает STARTTLS/direct TLS."
|
||||
)
|
||||
@@ -158,7 +159,7 @@ struct LoginView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 34)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.buttonStyle(.glassProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(model.isAuthenticating)
|
||||
|
||||
|
||||
@@ -53,10 +53,11 @@ private struct SearchField: View {
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
Color.secondary.opacity(0.14),
|
||||
in: RoundedRectangle(cornerRadius: 10)
|
||||
)
|
||||
.glassEffect()
|
||||
// .background(
|
||||
// Color.secondary.opacity(0.14),
|
||||
// in: RoundedRectangle(cornerRadius: 10)
|
||||
// )
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
@@ -85,16 +86,16 @@ private struct SearchField: View {
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .contacts: return "person.2"
|
||||
case .calls: return "phone"
|
||||
case .chats: return "bubble.left.and.bubble.right"
|
||||
case .settings: return "gearshape"
|
||||
case .contacts: return "person.circle.fill"
|
||||
case .calls: return "phone.fill"
|
||||
case .chats: return "bubble.left.and.bubble.right.fill"
|
||||
case .settings: return "gearshape.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var selectedIcon: String {
|
||||
switch self {
|
||||
case .contacts: return "person.2.fill"
|
||||
case .contacts: return "person.circle.fill"
|
||||
case .calls: return "phone.fill"
|
||||
case .chats: return "bubble.left.and.bubble.right.fill"
|
||||
case .settings: return "gearshape.fill"
|
||||
@@ -169,12 +170,12 @@ private struct ChatsTab: View {
|
||||
}
|
||||
.navigationTitle("")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Поиск чатов"
|
||||
)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Поиск чатов"
|
||||
)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
@@ -319,12 +320,12 @@ private struct ContactsTab: View {
|
||||
}
|
||||
.navigationTitle("")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Поиск контактов"
|
||||
)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Поиск контактов"
|
||||
)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
@@ -504,7 +505,7 @@ private struct CallsTab: View {
|
||||
}
|
||||
.navigationTitle("")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
@@ -596,9 +597,9 @@ private struct ContactRow: View {
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
// Image(systemName: "chevron.right")
|
||||
// .font(.caption.bold())
|
||||
// .foregroundStyle(.tertiary)
|
||||
// Image(systemName: "chevron.right")
|
||||
// .font(.caption.bold())
|
||||
// .foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
@@ -614,9 +615,9 @@ private struct ContactRow: View {
|
||||
#if os(macOS)
|
||||
private struct MainSplitView: View {
|
||||
private enum Mode: String, CaseIterable, Identifiable {
|
||||
case chats
|
||||
case contacts
|
||||
case calls
|
||||
case chats
|
||||
case settings
|
||||
|
||||
var id: String { rawValue }
|
||||
@@ -632,10 +633,10 @@ private struct ContactRow: View {
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .chats: return "bubble.left.and.bubble.right"
|
||||
case .contacts: return "person.2"
|
||||
case .calls: return "phone"
|
||||
case .settings: return "gearshape"
|
||||
case .chats: return "bubble.left.and.bubble.right.fill"
|
||||
case .contacts: return "person.circle.fill"
|
||||
case .calls: return "phone.fill"
|
||||
case .settings: return "gearshape.fill"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,30 +676,34 @@ private struct ContactRow: View {
|
||||
ideal: 290,
|
||||
max: 380
|
||||
)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text(mode.title)
|
||||
.font(.headline)
|
||||
}
|
||||
if mode != .settings {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button {
|
||||
showingNewGroup = true
|
||||
} label: {
|
||||
Image(systemName: "person.3.fill")
|
||||
}
|
||||
.accessibilityLabel("Новый групповой чат")
|
||||
.help("Новый групповой чат")
|
||||
Button {
|
||||
showingNewChat = true
|
||||
} label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
}
|
||||
.accessibilityLabel("Новый личный чат")
|
||||
.help("Новый личный чат")
|
||||
}
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
detailPane
|
||||
}
|
||||
.frame(minWidth: 840, minHeight: 560)
|
||||
.toolbar {
|
||||
if mode != .settings {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button {
|
||||
showingNewGroup = true
|
||||
} label: {
|
||||
Image(systemName: "person.3.fill")
|
||||
}
|
||||
.accessibilityLabel("Новый групповой чат")
|
||||
.help("Новый групповой чат")
|
||||
Button {
|
||||
showingNewChat = true
|
||||
} label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
}
|
||||
.accessibilityLabel("Новый личный чат")
|
||||
.help("Новый личный чат")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingNewChat) {
|
||||
NewChatView(model: model)
|
||||
}
|
||||
@@ -733,40 +738,41 @@ private struct ContactRow: View {
|
||||
|
||||
private var sidebar: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
Menu {
|
||||
ForEach(Mode.allCases) { item in
|
||||
Button {
|
||||
switchMode(item)
|
||||
} label: {
|
||||
Label(item.title, systemImage: item.icon)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.frame(width: 28, height: 28)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
.help("Меню")
|
||||
.accessibilityLabel("Меню")
|
||||
|
||||
if mode != .settings {
|
||||
SearchField(
|
||||
text: $searchText,
|
||||
prompt: mode.searchPrompt
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
if mode != .settings {
|
||||
ConnectionBanner(model: model, text: "")
|
||||
sidebarList
|
||||
SearchField(
|
||||
text: $searchText,
|
||||
prompt: mode.searchPrompt
|
||||
)
|
||||
}
|
||||
sidebarList
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Mode.allCases) { item in
|
||||
Button {
|
||||
withAnimation(.easeInOut) {
|
||||
switchMode(item)
|
||||
}
|
||||
} label: {
|
||||
VStack {
|
||||
Image(systemName: item.icon)
|
||||
.font(.largeTitle)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundColor(
|
||||
mode == item ? .accentColor : .secondary
|
||||
)
|
||||
.padding(.vertical, 8)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.animation(
|
||||
.spring(response: 0.35, dampingFraction: 0.82),
|
||||
value: mode
|
||||
)
|
||||
.glassEffect(in: .rect(cornerRadius: 20))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -780,7 +786,10 @@ private struct ContactRow: View {
|
||||
case .calls:
|
||||
callRows
|
||||
case .settings:
|
||||
EmptyView()
|
||||
// NavigationStack {
|
||||
SettingsView(model: model, presentedAsTab: true)
|
||||
// }
|
||||
// EmptyView()
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
@@ -837,7 +846,7 @@ private struct ContactRow: View {
|
||||
@ViewBuilder
|
||||
private var contactRows: some View {
|
||||
if !filteredRoster.isEmpty {
|
||||
Section("Люди из roster") {
|
||||
Section {
|
||||
ForEach(filteredRoster) { conversation in
|
||||
ContactRow(
|
||||
conversation: conversation,
|
||||
@@ -848,7 +857,7 @@ private struct ContactRow: View {
|
||||
}
|
||||
}
|
||||
if !filteredGroups.isEmpty {
|
||||
Section("Групповые чаты") {
|
||||
Section {
|
||||
ForEach(filteredGroups) { conversation in
|
||||
ContactRow(
|
||||
conversation: conversation,
|
||||
@@ -888,9 +897,11 @@ private struct ContactRow: View {
|
||||
@ViewBuilder
|
||||
private var detailPane: some View {
|
||||
if mode == .settings {
|
||||
NavigationStack {
|
||||
SettingsView(model: model, presentedAsTab: true)
|
||||
}
|
||||
ContentUnavailableView(
|
||||
emptyTitle,
|
||||
systemImage: "gearshape.fill",
|
||||
description: Text("Выберите пункт из списка слева.")
|
||||
)
|
||||
} else if let conversation = selectedConversation
|
||||
?? defaultConversation
|
||||
{
|
||||
|
||||
@@ -8,21 +8,10 @@ struct NewChatView: View {
|
||||
@State private var addToRoster = true
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Контакт") {
|
||||
TextField("user@example.org", text: $jid)
|
||||
.disableAutocorrection(true)
|
||||
#if os(iOS)
|
||||
.keyboardType(.emailAddress)
|
||||
#endif
|
||||
TextField("Имя (необязательно)", text: $name)
|
||||
}
|
||||
Section {
|
||||
Toggle("Добавить в roster", isOn: $addToRoster)
|
||||
} footer: {
|
||||
Text("Начать чат можно с любым полным JID. Для шифрования у контакта должно быть опубликовано хотя бы одно OMEMO-устройство.")
|
||||
}
|
||||
formcontent()
|
||||
}
|
||||
.navigationTitle("Новый чат")
|
||||
.toolbar {
|
||||
@@ -43,8 +32,48 @@ struct NewChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 380, minHeight: 300)
|
||||
#else
|
||||
List {
|
||||
formcontent()
|
||||
HStack {
|
||||
Button("Отмена") { dismiss() }
|
||||
.buttonStyle(.glass)
|
||||
.controlSize(.large)
|
||||
.tint(.secondary)
|
||||
Spacer()
|
||||
Button("Начать") {
|
||||
model.openConversation(
|
||||
jid: jid,
|
||||
name: name.isEmpty ? nil : name,
|
||||
addToRoster: addToRoster
|
||||
)
|
||||
if model.errorMessage == nil { dismiss() }
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
.disabled(jid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.buttonStyle(.glassProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Новый чат")
|
||||
.frame(minWidth: 380, minHeight: 220)
|
||||
#endif
|
||||
}
|
||||
@ViewBuilder
|
||||
private func formcontent() -> some View {
|
||||
Section("Контакт") {
|
||||
TextField("user@example.org", text: $jid)
|
||||
.disableAutocorrection(true)
|
||||
#if os(iOS)
|
||||
.keyboardType(.emailAddress)
|
||||
#endif
|
||||
TextField("Имя (необязательно)", text: $name)
|
||||
}
|
||||
Section {
|
||||
Toggle("Добавить в roster", isOn: $addToRoster)
|
||||
.toggleStyle(.switch)
|
||||
} footer: {
|
||||
Text("Начать чат можно с любым полным JID. Для шифрования у контакта должно быть опубликовано хотя бы одно OMEMO-устройство.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,65 +13,87 @@ struct NewGroupView: View {
|
||||
@State private var isJoining = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
TextField("room@conference.example.org", text: $roomJID)
|
||||
.disableAutocorrection(true)
|
||||
#if os(iOS)
|
||||
.keyboardType(.emailAddress)
|
||||
#endif
|
||||
TextField("Название (необязательно)", text: $name)
|
||||
TextField("Ваш псевдоним", text: $nickname)
|
||||
}
|
||||
} label: {
|
||||
Text("Комната")
|
||||
.font(.headline)
|
||||
#if os(iOS)
|
||||
NavigationStack {
|
||||
Form {
|
||||
formcontent()
|
||||
}
|
||||
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextField("alice@example.org, bob@example.org", text: $invitees, axis: .vertical)
|
||||
.lineLimit(2...5)
|
||||
#if os(iOS)
|
||||
.keyboardType(.emailAddress)
|
||||
#endif
|
||||
Text("Укажите JID через запятую или с новой строки. Если комнаты ещё нет, Prosody создаст её с настройками по умолчанию.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.navigationTitle("Новая группа")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Отмена") { dismiss() }
|
||||
.disabled(isJoining)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Создать / войти") { join() }
|
||||
.fontWeight(.semibold)
|
||||
.disabled(!canJoin || isJoining)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if nickname.isEmpty {
|
||||
nickname = model.suggestedGroupNickname
|
||||
}
|
||||
} label: {
|
||||
Text("Пригласить")
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
Label(
|
||||
"Новая комната настраивается как неанонимная и доступная участникам, чтобы поддерживать групповое OMEMO. Режим шифрования можно изменить в меню замка внутри чата.",
|
||||
systemImage: "lock.shield.fill"
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.navigationTitle("Новая группа")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
#else
|
||||
List {
|
||||
formcontent()
|
||||
HStack {
|
||||
Button("Отмена") { dismiss() }
|
||||
.disabled(isJoining)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
.buttonStyle(.glass)
|
||||
.tint(.secondary)
|
||||
.controlSize(.large)
|
||||
Spacer()
|
||||
Button("Создать / войти") { join() }
|
||||
.fontWeight(.semibold)
|
||||
.disabled(!canJoin || isJoining)
|
||||
.buttonStyle(.glassProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Новая группа")
|
||||
.onAppear {
|
||||
if nickname.isEmpty { nickname = model.suggestedGroupNickname }
|
||||
}
|
||||
.frame(minWidth: 480, minHeight: 330)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func formcontent() -> some View {
|
||||
Section("Комната") {
|
||||
TextField("room@conference.example.org", text: $roomJID)
|
||||
.disableAutocorrection(true)
|
||||
#if os(iOS)
|
||||
.keyboardType(.emailAddress)
|
||||
#endif
|
||||
TextField("Название (необязательно)", text: $name)
|
||||
TextField("Ваш псевдоним", text: $nickname)
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 480, minHeight: 470)
|
||||
#endif
|
||||
Section("Пригласить") {
|
||||
TextField(
|
||||
"alice@example.org, bob@example.org",
|
||||
text: $invitees,
|
||||
axis: .vertical
|
||||
)
|
||||
.lineLimit(2...5)
|
||||
#if os(iOS)
|
||||
.keyboardType(.emailAddress)
|
||||
#endif
|
||||
Text(
|
||||
"Укажите JID через запятую или с новой строки. Если комнаты ещё нет, Prosody создаст её с настройками по умолчанию."
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Label(
|
||||
"Новая комната настраивается как неанонимная и доступная участникам, чтобы поддерживать групповое OMEMO. Режим шифрования можно изменить в меню замка внутри чата.",
|
||||
systemImage: "lock.shield.fill"
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private var canJoin: Bool {
|
||||
|
||||
@@ -8,76 +8,26 @@ struct ServerInfoView: View {
|
||||
@State private var isLoading = false
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
if let info = model.serverInformation {
|
||||
Section("Это статистика вашего соединения.") {
|
||||
statisticsEntries(info)
|
||||
} //header: {
|
||||
// Text("Это статистика вашего соединения.")
|
||||
// }
|
||||
|
||||
Section {
|
||||
softwareEntry(info)
|
||||
} header: {
|
||||
Text("Это программное обеспечение, работающее на вашем сервере.")
|
||||
}
|
||||
|
||||
Section {
|
||||
connectionRows()
|
||||
} header: {
|
||||
Text("Параметры подключения к вашему серверу.")
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(info.capabilities) { capability in
|
||||
entry(
|
||||
title: capability.title,
|
||||
detail: capability.detail,
|
||||
status: capability.status
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("Современные возможности XMPP, обнаруженные на вашем сервере после входа.")
|
||||
}
|
||||
|
||||
Section {
|
||||
mucEntries(info)
|
||||
} header: {
|
||||
Text("MUC-серверы, обнаруженные на вашем сервере.")
|
||||
}
|
||||
|
||||
Section {
|
||||
stunTurnEntries(info)
|
||||
} header: {
|
||||
Text("STUN и TURN сервисы, объявленные вашим сервером.")
|
||||
}
|
||||
|
||||
Section {
|
||||
saslEntries(info)
|
||||
} header: {
|
||||
Text("Методы аутентификации SASL, которые поддерживает ваш сервер.")
|
||||
}
|
||||
} else if isLoading {
|
||||
Section {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Загрузка информации о сервере…")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ContentUnavailableView(
|
||||
"Нет данных о сервере",
|
||||
systemImage: "server.rack",
|
||||
description: Text(
|
||||
model.connectionStatus == .connected
|
||||
? "Сервер не ответил на запрос. Попробуйте обновить."
|
||||
: "Подключитесь к серверу, чтобы увидеть его возможности."
|
||||
)
|
||||
)
|
||||
}
|
||||
infocontent()
|
||||
}
|
||||
.navigationTitle(model.account?.domain ?? "Сервер")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
Task { await load() }
|
||||
} label: {
|
||||
Label("Обновить", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(isLoading)
|
||||
}
|
||||
}
|
||||
.task { await load() }
|
||||
.listStyle(.grouped)
|
||||
#else
|
||||
List {
|
||||
infocontent()
|
||||
}
|
||||
.navigationTitle(model.account?.domain ?? "Сервер")
|
||||
.toolbar {
|
||||
@@ -91,12 +41,62 @@ struct ServerInfoView: View {
|
||||
}
|
||||
}
|
||||
.task { await load() }
|
||||
#if os(macOS)
|
||||
.listStyle(.inset)
|
||||
.frame(minWidth: 480, minHeight: 420)
|
||||
#else
|
||||
.listStyle(.grouped)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func infocontent() -> some View {
|
||||
if let info = model.serverInformation {
|
||||
Section("Это статистика вашего соединения.") {
|
||||
statisticsEntries(info)
|
||||
}
|
||||
Section("Это программное обеспечение, работающее на вашем сервере.") {
|
||||
softwareEntry(info)
|
||||
}
|
||||
Section("Параметры подключения к вашему серверу.") {
|
||||
connectionRows()
|
||||
}
|
||||
Section("Современные возможности XMPP, обнаруженные на вашем сервере после входа.") {
|
||||
ForEach(info.capabilities) { capability in
|
||||
entry(
|
||||
title: capability.title,
|
||||
detail: capability.detail,
|
||||
status: capability.status
|
||||
)
|
||||
}
|
||||
}
|
||||
Section("MUC-серверы, обнаруженные на вашем сервере.") {
|
||||
mucEntries(info)
|
||||
}
|
||||
Section("STUN и TURN сервисы, объявленные вашим сервером.") {
|
||||
stunTurnEntries(info)
|
||||
}
|
||||
Section("Методы аутентификации SASL, которые поддерживает ваш сервер.") {
|
||||
saslEntries(info)
|
||||
}
|
||||
} else if isLoading {
|
||||
Section {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Загрузка информации о сервере…")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ContentUnavailableView(
|
||||
"Нет данных о сервере",
|
||||
systemImage: "server.rack",
|
||||
description: Text(
|
||||
model.connectionStatus == .connected
|
||||
? "Сервер не ответил на запрос. Попробуйте обновить."
|
||||
: "Подключитесь к серверу, чтобы увидеть его возможности."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
@@ -14,182 +14,74 @@ struct SettingsView: View {
|
||||
@State private var passcodeSheetMode: AppLockPasscodeSheet.Mode?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
if let account = model.account {
|
||||
Section("Профиль") {
|
||||
HStack(spacing: 14) {
|
||||
AvatarView(
|
||||
conversation: Conversation(
|
||||
jid: account.normalizedJID,
|
||||
displayName: account.displayName.isEmpty ? nil : account.displayName
|
||||
),
|
||||
imageData: model.avatarData(for: account.normalizedJID),
|
||||
size: 64
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(account.displayName.isEmpty ? account.normalizedJID : account.displayName)
|
||||
.font(.headline)
|
||||
PhotosPicker(selection: $avatarItem, matching: .images) {
|
||||
Label("Обновить аватар", systemImage: "photo.badge.plus")
|
||||
}
|
||||
.disabled(model.isUpdatingAvatar)
|
||||
}
|
||||
Spacer()
|
||||
if model.isUpdatingAvatar {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Аккаунт") {
|
||||
LabeledContent("JID", value: account.normalizedJID)
|
||||
LabeledContent("Ресурс", value: account.effectiveResource)
|
||||
if let host = account.manualHost {
|
||||
LabeledContent("Сервер", value: "\(host):\(account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222))")
|
||||
} else {
|
||||
LabeledContent("Обнаружение", value: "DNS SRV")
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
NavigationStack {
|
||||
Form {
|
||||
formcontent()
|
||||
}
|
||||
|
||||
Section("Соединение") {
|
||||
HStack {
|
||||
Label(connectionTitle, systemImage: connectionIcon)
|
||||
.foregroundStyle(connectionColor)
|
||||
Spacer()
|
||||
if model.connectionStatus != .connected {
|
||||
Button("Подключить") { Task { await model.reconnect() } }
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("Настройки")
|
||||
.font(.headline)
|
||||
}
|
||||
if !presentedAsTab {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Готово") { dismiss() }
|
||||
}
|
||||
}
|
||||
LabeledContent("История MAM", value: model.isArchiveSyncing ? "Синхронизация" : "Готово")
|
||||
NavigationLink {
|
||||
ServerInfoView(model: model)
|
||||
} label: {
|
||||
Label("Информация о сервере", systemImage: "server.rack")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Шифрование") {
|
||||
Toggle("OMEMO по умолчанию", isOn: Binding(
|
||||
get: { model.globalEncryptionEnabled },
|
||||
set: { model.setGlobalEncryptionEnabled($0) }
|
||||
))
|
||||
Text("Для отдельного чата режим можно изменить через значок замка в его панели.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if !model.globalEncryptionEnabled {
|
||||
Label("Новые сообщения без отдельного правила отправляются открытым текстом.", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
LabeledContent("Состояние", value: model.isOMEMOReady ? "Активно" : "Подготовка")
|
||||
if let fingerprint = model.ownFingerprint {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Text("Отпечаток этого устройства")
|
||||
.font(.subheadline)
|
||||
Text(fingerprint.chunked(every: 8).joined(separator: " "))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Уведомления") {
|
||||
Label("Локальные уведомления включаются после входа", systemImage: "bell.badge")
|
||||
Text("Для доставки при выгруженном iOS-приложении требуется APNs и XEP-0357 push-шлюз приложения. Обычный XMPP-сокет не может постоянно работать в фоне.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Блокировка приложения") {
|
||||
Toggle("Заблокировать приложение", isOn: Binding(
|
||||
get: { model.appLockIsEnabled },
|
||||
set: { enabled in
|
||||
if enabled {
|
||||
passcodeSheetMode = .setup
|
||||
} else {
|
||||
passcodeSheetMode = .verify(.disableLock)
|
||||
}
|
||||
}
|
||||
))
|
||||
if model.appLockIsEnabled {
|
||||
Toggle("Вход по \(model.biometricUnlockName)", isOn: Binding(
|
||||
get: { model.appLockBiometricIsEnabled },
|
||||
set: { enabled in
|
||||
passcodeSheetMode = .verify(
|
||||
enabled ? .enableBiometrics : .disableBiometrics
|
||||
)
|
||||
}
|
||||
))
|
||||
.disabled(!model.biometricUnlockAvailable)
|
||||
Button("Сменить пароль") {
|
||||
passcodeSheetMode = .change
|
||||
}
|
||||
}
|
||||
Text("При включённой блокировке Luma запрашивает пароль при запуске и после сворачивания. Пароль хранится в Keychain.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Приватность чата") {
|
||||
Toggle("Показывать статус набора", isOn: Binding(
|
||||
get: { model.typingIndicatorsEnabled },
|
||||
set: { model.setTypingIndicatorsEnabled($0) }
|
||||
))
|
||||
Text("Когда включено, Luma показывает «печатает…» и отправляет собеседникам стандартные XMPP-состояния набора.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Удалить локальную историю при выходе", isOn: $forgetHistory)
|
||||
|
||||
Button(role: .destructive) {
|
||||
showingSignOutConfirmation = true
|
||||
} label: {
|
||||
Label("Выйти из аккаунта", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("Настройки")
|
||||
#else
|
||||
formcontent()
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func formcontent() -> some View {
|
||||
if let account = model.account {
|
||||
Section("Профиль") {
|
||||
HStack(spacing: 14) {
|
||||
AvatarView(
|
||||
conversation: Conversation(
|
||||
jid: account.normalizedJID,
|
||||
displayName: account.displayName.isEmpty
|
||||
? nil : account.displayName
|
||||
),
|
||||
imageData: model.avatarData(for: account.normalizedJID),
|
||||
size: 64
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(
|
||||
account.displayName.isEmpty
|
||||
? account.normalizedJID : account.displayName
|
||||
)
|
||||
.font(.headline)
|
||||
}
|
||||
if !presentedAsTab {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Готово") { dismiss() }
|
||||
PhotosPicker(selection: $avatarItem, matching: .images)
|
||||
{
|
||||
Label(
|
||||
"Обновить аватар",
|
||||
systemImage: "photo.badge.plus"
|
||||
)
|
||||
}
|
||||
.disabled(model.isUpdatingAvatar)
|
||||
}
|
||||
Spacer()
|
||||
if model.isUpdatingAvatar {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Выйти из Luma?",
|
||||
isPresented: $showingSignOutConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Выйти", role: .destructive) {
|
||||
Task {
|
||||
await model.signOut(forgetHistory: forgetHistory)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
Button("Отмена", role: .cancel) {}
|
||||
} message: {
|
||||
Text(forgetHistory
|
||||
? "Пароль и локальная история будут удалены. Серверная история MAM не изменится."
|
||||
: "Пароль будет удалён из Keychain. Серверная история MAM не изменится.")
|
||||
}
|
||||
.onChange(of: avatarItem) { _, item in
|
||||
guard let item else { return }
|
||||
Task {
|
||||
do {
|
||||
guard let data = try await item.loadTransferable(type: Data.self) else { return }
|
||||
guard
|
||||
let data = try await item.loadTransferable(
|
||||
type: Data.self
|
||||
)
|
||||
else { return }
|
||||
await model.updateOwnAvatar(from: data)
|
||||
avatarItem = nil
|
||||
} catch {
|
||||
@@ -197,13 +89,187 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Аккаунт") {
|
||||
LabeledContent("JID", value: account.normalizedJID)
|
||||
LabeledContent("Ресурс", value: account.effectiveResource)
|
||||
if let host = account.manualHost {
|
||||
LabeledContent(
|
||||
"Сервер",
|
||||
value:
|
||||
"\(host):\(account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222))"
|
||||
)
|
||||
} else {
|
||||
LabeledContent("Обнаружение", value: "DNS SRV")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Соединение") {
|
||||
HStack {
|
||||
Label(connectionTitle, systemImage: connectionIcon)
|
||||
.foregroundStyle(connectionColor)
|
||||
Spacer()
|
||||
if model.connectionStatus != .connected {
|
||||
Button("Подключить") { Task { await model.reconnect() } }
|
||||
}
|
||||
}
|
||||
LabeledContent(
|
||||
"История MAM",
|
||||
value: model.isArchiveSyncing ? "Синхронизация" : "Готово"
|
||||
)
|
||||
NavigationLink {
|
||||
ServerInfoView(model: model)
|
||||
} label: {
|
||||
Label("Информация о сервере", systemImage: "server.rack")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Шифрование") {
|
||||
Toggle(
|
||||
"OMEMO по умолчанию",
|
||||
isOn: Binding(
|
||||
get: { model.globalEncryptionEnabled },
|
||||
set: { model.setGlobalEncryptionEnabled($0) }
|
||||
)
|
||||
)
|
||||
.toggleStyle(.switch)
|
||||
Text(
|
||||
"Для отдельного чата режим можно изменить через значок замка в его панели."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if !model.globalEncryptionEnabled {
|
||||
Label(
|
||||
"Новые сообщения без отдельного правила отправляются открытым текстом.",
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
LabeledContent(
|
||||
"Состояние",
|
||||
value: model.isOMEMOReady ? "Активно" : "Подготовка"
|
||||
)
|
||||
if let fingerprint = model.ownFingerprint {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Text("Отпечаток этого устройства")
|
||||
.font(.subheadline)
|
||||
Text(fingerprint.chunked(every: 8).joined(separator: " "))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Уведомления") {
|
||||
Label(
|
||||
"Локальные уведомления включаются после входа",
|
||||
systemImage: "bell.badge"
|
||||
)
|
||||
Text(
|
||||
"Для доставки при выгруженном iOS-приложении требуется APNs и XEP-0357 push-шлюз приложения. Обычный XMPP-сокет не может постоянно работать в фоне."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Блокировка приложения") {
|
||||
Toggle(
|
||||
"Заблокировать приложение",
|
||||
isOn: Binding(
|
||||
get: { model.appLockIsEnabled },
|
||||
set: { enabled in
|
||||
if enabled {
|
||||
passcodeSheetMode = .setup
|
||||
} else {
|
||||
passcodeSheetMode = .verify(.disableLock)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
.toggleStyle(.switch)
|
||||
.sheet(item: $passcodeSheetMode) { mode in
|
||||
AppLockPasscodeSheet(model: model, mode: mode)
|
||||
}
|
||||
if model.appLockIsEnabled {
|
||||
Toggle(
|
||||
"Вход по \(model.biometricUnlockName)",
|
||||
isOn: Binding(
|
||||
get: { model.appLockBiometricIsEnabled },
|
||||
set: { enabled in
|
||||
passcodeSheetMode = .verify(
|
||||
enabled ? .enableBiometrics : .disableBiometrics
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
.toggleStyle(.switch)
|
||||
.disabled(!model.biometricUnlockAvailable)
|
||||
Button("Сменить пароль") {
|
||||
passcodeSheetMode = .change
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"При включённой блокировке Luma запрашивает пароль при запуске и после сворачивания. Пароль хранится в Keychain."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Приватность чата") {
|
||||
Toggle(
|
||||
"Показывать статус набора",
|
||||
isOn: Binding(
|
||||
get: { model.typingIndicatorsEnabled },
|
||||
set: { model.setTypingIndicatorsEnabled($0) }
|
||||
)
|
||||
)
|
||||
.toggleStyle(.switch)
|
||||
Text(
|
||||
"Когда включено, Luma показывает «печатает…» и отправляет собеседникам стандартные XMPP-состояния набора."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Удалить локальную историю при выходе", isOn: $forgetHistory)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Button(role: .destructive) {
|
||||
showingSignOutConfirmation = true
|
||||
} label: {
|
||||
Label(
|
||||
"Выйти из аккаунта",
|
||||
systemImage: "rectangle.portrait.and.arrow.right"
|
||||
)
|
||||
}
|
||||
#if os(macOS)
|
||||
.buttonStyle(.glassProminent)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Выйти из Luma?",
|
||||
isPresented: $showingSignOutConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Выйти", role: .destructive) {
|
||||
Task {
|
||||
await model.signOut(forgetHistory: forgetHistory)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
Button("Отмена", role: .cancel) {}
|
||||
} message: {
|
||||
Text(
|
||||
forgetHistory
|
||||
? "Пароль и локальная история будут удалены. Серверная история MAM не изменится."
|
||||
: "Пароль будет удалён из Keychain. Серверная история MAM не изменится."
|
||||
)
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 500, minHeight: 560)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var connectionTitle: String {
|
||||
@@ -231,13 +297,14 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func chunked(every size: Int) -> [String] {
|
||||
extension String {
|
||||
fileprivate func chunked(every size: Int) -> [String] {
|
||||
guard size > 0 else { return [self] }
|
||||
var result: [String] = []
|
||||
var cursor = startIndex
|
||||
while cursor < endIndex {
|
||||
let end = index(cursor, offsetBy: size, limitedBy: endIndex) ?? endIndex
|
||||
let end =
|
||||
index(cursor, offsetBy: size, limitedBy: endIndex) ?? endIndex
|
||||
result.append(String(self[cursor..<end]))
|
||||
cursor = end
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user