updated ui for macos and ios
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

This commit is contained in:
wt committed 2026-08-31 19:11:38 +07:00
1 parent df7ab24677
commit e3fcee3351
10 files changed
+619 -576

No files matched your search

+5 -5
View File
@@ -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;
+87 -60
View File
@@ -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 {
}
}
}
+9 -100
View File
@@ -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))
}
}
+2 -1
View File
@@ -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)
+98 -87
View File
@@ -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
{
+44 -15
View File
@@ -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-устройство.")
}
}
}
+66 -44
View File
@@ -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 {
+72 -72
View File
@@ -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
+236 -169
View File
@@ -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
}