add basic ios implementation

This commit is contained in:
2026-08-04 08:24:40 +07:00
parent 7009dbf692
commit e3fec77359
34 changed files with 2671 additions and 22 deletions
+4 -1
View File
@@ -4,4 +4,7 @@ PRODUCT_NAME=Coop
PRODUCT_BUNDLE_IDENTIFIER=su.reya.coop.Coop$(TEAM_ID)
CURRENT_PROJECT_VERSION=1
MARKETING_VERSION=1.0
MARKETING_VERSION=1.0
FRAMEWORK_SEARCH_PATHS=$(inherited) "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"
OTHER_LDFLAGS=$(inherited) -framework Shared
+14
View File
@@ -0,0 +1,14 @@
import Foundation
enum AppRoute: Hashable {
case home
case requestList
case contactList
case updateProfile
case newChat
case myQr
case relay
case settings
case chat(id: Int64, screening: Bool)
case profile(pubkey: String)
}
+90
View File
@@ -0,0 +1,90 @@
import Foundation
import Shared
@MainActor
@Observable
final class AppState {
let bootstrap: IosBootstrap
private var subscriptions: [FlowSubscription] = []
var path: [AppRoute] = []
var signerRequired: Bool?
var isSyncing = false
var partialProcessed = false
var chatRooms: [Room] = []
var accountState: AccountState?
var settings: Settings?
var currentUserProfile: Profile?
var isUpdatingProfile = false
var errorMessage: String?
let networkMonitor = NetworkMonitor()
init() {
bootstrap = IosBootstrap.companion.create(storage: IosAppStorage())
}
func start() {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let dbDir = docs.appendingPathComponent("nostr", isDirectory: true)
try? FileManager.default.createDirectory(at: dbDir, withIntermediateDirectories: true)
bootstrap.start(dbPath: dbDir.path) { [weak self] event in
self?.handleNewMessage(event)
}
subscriptions.append(bootstrap.watchAccountState { [weak self] state in
self?.accountState = state
self?.signerRequired = state.signerRequired?.boolValue
})
subscriptions.append(bootstrap.watchChatRooms { [weak self] rooms in
self?.chatRooms = rooms
})
subscriptions.append(bootstrap.watchIsSyncing { [weak self] value in
self?.isSyncing = value.boolValue
})
subscriptions.append(bootstrap.watchPartialProcessed { [weak self] value in
self?.partialProcessed = value.boolValue
})
subscriptions.append(bootstrap.watchSettings { [weak self] settings in
self?.settings = settings
})
subscriptions.append(bootstrap.watchCurrentUserProfile { [weak self] profile in
self?.currentUserProfile = profile
})
subscriptions.append(bootstrap.watchIsUpdatingProfile { [weak self] value in
self?.isUpdatingProfile = value.boolValue
})
subscriptions.append(bootstrap.watchErrors { [weak self] message in
self?.errorMessage = message
})
}
func handle(_ url: URL) {
guard url.scheme == "coop" else { return }
switch url.host {
case "chat":
if let id = Int64(url.pathComponents.dropFirst().first ?? "") {
path.append(.chat(id: id, screening: false))
}
case "profile":
let pubkey = url.pathComponents.dropFirst().first ?? ""
if !pubkey.isEmpty {
path.append(.profile(pubkey: pubkey))
}
default:
break
}
}
func logout() {
bootstrap.logout()
bootstrap.resetState()
path = []
}
private func handleNewMessage(_ event: Nostr_sdk_kmpUnsignedEvent) {
NotificationService.shared.notifyNewMessage(roomId: event.roomId(), content: event.content())
}
}
+68 -20
View File
@@ -2,32 +2,80 @@ import SwiftUI
import Shared
struct ContentView: View {
@State private var showContent = false
var body: some View {
VStack {
Button("Click me!") {
withAnimation {
showContent = !showContent
}
}
@Environment(AppState.self) private var appState
if showContent {
VStack(spacing: 16) {
Image(systemName: "swift")
.font(.system(size: 200))
.foregroundColor(.accentColor)
Text("SwiftUI: \(Greeting().greet())")
var body: some View {
Group {
switch appState.signerRequired {
case .none:
SplashView()
case .some(true):
OnboardingView()
case .some(false):
NavigationStack(path: Bindable(appState).path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
destination(for: route)
}
}
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding()
.alert("Error", isPresented: Binding(
get: { appState.errorMessage != nil },
set: { if !$0 { appState.errorMessage = nil } }
)) {
Button("OK") { appState.errorMessage = nil }
} message: {
Text(appState.errorMessage ?? "")
}
.preferredColorScheme(colorScheme)
}
private var colorScheme: ColorScheme? {
switch appState.settings?.theme {
case Theme.light:
return .light
case Theme.dark:
return .dark
default:
return nil
}
}
@ViewBuilder
private func destination(for route: AppRoute) -> some View {
switch route {
case .home:
HomeView()
case .requestList:
RequestListView()
case .contactList:
ContactListView()
case .updateProfile:
UpdateProfileView()
case .newChat:
NewChatView()
case .myQr:
MyQrView()
case .relay:
RelayView()
case .settings:
SettingsView()
case .chat(let id, let screening):
ChatView(roomId: id, screening: screening)
case .profile(let pubkey):
ProfileView(pubkey: pubkey)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
struct SplashView: View {
var body: some View {
ZStack {
Color(.systemBackground).ignoresSafeArea()
Image(systemName: "bubble.left.and.bubble.right.fill")
.font(.system(size: 64))
.foregroundStyle(.tint)
}
}
}
+13
View File
@@ -4,5 +4,18 @@
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Coop uses the camera to scan QR codes for adding contacts and importing identities.</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>su.reya.coop</string>
<key>CFBundleURLSchemes</key>
<array>
<string>coop</string>
</array>
</dict>
</array>
</dict>
</plist>
@@ -0,0 +1,73 @@
import Foundation
import Security
import Shared
final class IosAppStorage: AppStorage {
private let defaults = UserDefaults.standard
private let service = "su.reya.coop"
func get(key: String, completionHandler: @escaping (String?, Error?) -> Void) {
completionHandler(defaults.string(forKey: key), nil)
}
func set(key: String, value: String, completionHandler: @escaping (Error?) -> Void) {
defaults.set(value, forKey: key)
completionHandler(nil)
}
func getSecret(key: String, completionHandler: @escaping (String?, Error?) -> Void) {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else {
completionHandler(nil, nil)
return
}
completionHandler(String(data: data, encoding: .utf8), nil)
}
func setSecret(key: String, value: String, completionHandler: @escaping (Error?) -> Void) {
let data = Data(value.utf8)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
]
if SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess {
SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary)
} else {
var add = query
add[kSecValueData] = data
add[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
SecItemAdd(add as CFDictionary, nil)
}
completionHandler(nil)
}
func clear(key: String, completionHandler: @escaping (Error?) -> Void) {
defaults.removeObject(forKey: key)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
]
SecItemDelete(query as CFDictionary)
completionHandler(nil)
}
func has(key: String, completionHandler: @escaping (KotlinBoolean?, Error?) -> Void) {
if defaults.object(forKey: key) != nil {
completionHandler(KotlinBoolean(booleanLiteral: true), nil)
return
}
getSecret(key: key) { secret, _ in
completionHandler(KotlinBoolean(booleanLiteral: secret != nil), nil)
}
}
}
@@ -0,0 +1,26 @@
import Foundation
import Shared
extension Data {
func toKotlinByteArray() -> KotlinByteArray {
let array = KotlinByteArray(size: Int32(count))
for (index, byte) in enumerated() {
array.set(index: Int32(index), value: Int8(bitPattern: byte))
}
return array
}
}
extension KotlinBoolean {
var value: Bool { boolValue }
}
extension String {
func isImageUrl() -> Bool {
ExtensionsKt.isImageUrl(self)
}
func removeImageUrls() -> String {
ExtensionsKt.removeImageUrls(self)
}
}
@@ -0,0 +1,24 @@
import Foundation
import Network
@Observable
final class NetworkMonitor {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "su.reya.coop.network")
private(set) var isMobileData = false
init() {
monitor.pathUpdateHandler = { [weak self] path in
let mobile = path.usesInterfaceType(.cellular)
Task { @MainActor in
self?.isMobileData = mobile
}
}
monitor.start(queue: queue)
}
deinit {
monitor.cancel()
}
}
@@ -0,0 +1,56 @@
import Foundation
import UserNotifications
final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationService()
private let center = UNUserNotificationCenter.current()
var onOpenChat: ((Int64) -> Void)?
override init() {
super.init()
center.delegate = self
}
func requestPermissionIfNeeded() {
center.getNotificationSettings { settings in
if settings.authorizationStatus == .notDetermined {
self.center.requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }
}
}
}
func notifyNewMessage(roomId: Int64, content: String) {
let notificationContent = UNMutableNotificationContent()
notificationContent.title = "You received a new message"
notificationContent.body = content
notificationContent.sound = .default
notificationContent.userInfo = ["roomId": roomId]
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: notificationContent,
trigger: nil
)
center.add(request)
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
[]
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
if let roomId = response.notification.request.content.userInfo["roomId"] as? Int64 {
await MainActor.run {
onOpenChat?(roomId)
}
}
}
}
+236
View File
@@ -0,0 +1,236 @@
import PhotosUI
import SwiftUI
import Shared
struct ChatView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
let roomId: Int64
let screening: Bool
@State private var viewModel: ChatViewModel?
@State private var input = ""
@State private var photoItem: PhotosPickerItem?
@State private var authorNames: [String: String] = [:]
private var showScreener: Bool {
(viewModel?.requireScreening ?? false) && appState.settings?.screening == true
}
var body: some View {
VStack(spacing: 0) {
messageList
if showScreener, let room = viewModel?.room {
ScreenerCard(
room: room,
onAccept: { viewModel?.requireScreening = false },
onReject: { dismiss() }
)
} else {
inputBar
}
}
.navigationTitle(viewModel?.roomUi?.name ?? "Chat")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
if let room = viewModel?.room, let firstMember = otherMember(of: room) {
Button {
appState.path.append(.profile(pubkey: firstMember.toHex()))
} label: {
VStack(spacing: 2) {
AvatarView(
name: viewModel?.roomUi?.name ?? "?",
picture: viewModel?.roomUi?.picture,
size: 28
)
Text(viewModel?.roomUi?.name ?? "Chat")
.font(.caption)
.foregroundStyle(.primary)
}
}
} else {
Text(viewModel?.roomUi?.name ?? "Chat").font(.headline)
}
}
}
.task {
let vm = ChatViewModel(roomId: roomId, screening: screening)
viewModel = vm
vm.start(appState: appState)
}
.onDisappear {
viewModel?.stop()
}
.task(id: photoItem) {
guard let photoItem else { return }
guard let data = try? await photoItem.loadTransferable(type: Data.self) else { return }
let contentType = photoItem.supportedContentTypes.first?.preferredMIMEType ?? "image/jpeg"
viewModel?.sendImage(data, contentType: contentType, appState: appState)
self.photoItem = nil
}
}
private var messageList: some View {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(groupedMessages, id: \.0) { group, messages in
Text(group)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 8)
ForEach(messages, id: \.stableId) { event in
let isMine = event.author().toHex() == appState.bootstrap.currentPublicKey()?.toHex()
let replyId = event.tags().eventIds().first
let replied = replyId.flatMap { id in
viewModel?.messages.first { $0.id()?.toHex() == id.toHex() }
}
MessageBubble(
event: event,
isMine: isMine,
showImages: showImages,
repliedMessage: replied,
repliedAuthorName: replied.flatMap { authorName(for: $0) },
onReply: { viewModel?.replyingTo = event }
)
}
}
}
.padding(.horizontal)
}
.defaultScrollAnchor(.bottom)
.overlay {
if viewModel?.loading == true {
ProgressView()
}
}
}
private var inputBar: some View {
VStack(spacing: 8) {
if let replyingTo = viewModel?.replyingTo {
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.accentColor)
.frame(width: 3)
VStack(alignment: .leading, spacing: 1) {
Text("Replying to \(authorName(for: replyingTo))")
.font(.caption.bold())
Text(replyingTo.content())
.font(.caption)
.lineLimit(1)
.foregroundStyle(.secondary)
}
Spacer()
Button {
viewModel?.replyingTo = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
.padding(.top, 8)
}
HStack(spacing: 12) {
PhotosPicker(selection: $photoItem, matching: .images) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(.tint)
}
TextField("Message", text: $input, axis: .vertical)
.lineLimit(1...5)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.secondarySystemBackground), in: Capsule())
Button {
viewModel?.send(input, appState: appState)
input = ""
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
.foregroundStyle(input.isEmpty ? Color(.systemGray3) : Color.accentColor)
}
.disabled(input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
.background(.bar)
}
private var groupedMessages: [(String, [Nostr_sdk_kmpUnsignedEvent])] {
let messages = viewModel?.messages ?? []
var groups: [(String, [Nostr_sdk_kmpUnsignedEvent])] = []
var currentKey = ""
for message in messages {
let key = message.createdAt().formatAsGroup()
if key != currentKey {
groups.append((key, [message]))
currentKey = key
} else {
groups[groups.count - 1].1.append(message)
}
}
return groups
}
private var showImages: Bool {
guard let media = appState.settings?.media else { return true }
switch media {
case MediaConfig.disabled:
return false
case MediaConfig.disabledformobiledata:
return !appState.networkMonitor.isMobileData
default:
return true
}
}
private func otherMember(of room: Room) -> Nostr_sdk_kmpPublicKey? {
let selfHex = appState.bootstrap.currentPublicKey()?.toHex()
return room.members.first { $0.toHex() != selfHex } ?? room.members.first
}
private func authorName(for event: Nostr_sdk_kmpUnsignedEvent) -> String {
let hex = event.author().toHex()
if hex == appState.bootstrap.currentPublicKey()?.toHex() {
return appState.currentUserProfile?.name ?? "You"
}
if let cached = authorNames[hex] {
return cached
}
let short = event.author().short()
loadAuthorName(pubkey: event.author(), hex: hex)
return short
}
private func loadAuthorName(pubkey: Nostr_sdk_kmpPublicKey, hex: String) {
guard authorNames[hex] == nil else { return }
authorNames[hex] = pubkey.short()
let sub = appState.bootstrap.watchProfile(pubkey: pubkey) { profile in
Task { @MainActor in
if let profile {
authorNames[hex] = profile.name
}
}
}
Task { @MainActor in
try? await Task.sleep(for: .seconds(30))
sub.cancel()
}
}
}
private extension Nostr_sdk_kmpUnsignedEvent {
var stableId: String {
id()?.toHex() ?? "\(createdAt().asSecs())-\(content().hashValue)"
}
}
@@ -0,0 +1,82 @@
import SwiftUI
import Shared
@MainActor
@Observable
final class ChatViewModel {
let roomId: Int64
var messages: [Nostr_sdk_kmpUnsignedEvent] = []
var room: Room?
var roomUi: RoomUiState?
var loading = true
var replyingTo: Nostr_sdk_kmpUnsignedEvent?
var requireScreening: Bool
private var subscriptions: [FlowSubscription] = []
init(roomId: Int64, screening: Bool) {
self.roomId = roomId
self.requireScreening = screening
}
func start(appState: AppState) {
let bootstrap = appState.bootstrap
room = bootstrap.getChatRoom(id: roomId)
if let room {
subscriptions.append(bootstrap.watchRoomUi(
room: room,
currentUser: bootstrap.currentPublicKey()
) { [weak self] state in
Task { @MainActor in self?.roomUi = state }
})
}
subscriptions.append(bootstrap.watchNewEvents { [weak self] event in
Task { @MainActor in
guard let self, event.roomId() == self.roomId else { return }
if !self.messages.contains(where: { $0.id()?.toHex() == event.id()?.toHex() }) {
self.messages.append(event)
self.messages.sort { $0.createdAt().asSecs() < $1.createdAt().asSecs() }
}
appState.bootstrap.markRoomRead(id: self.roomId)
}
})
Task {
let loaded = (try? await bootstrap.loadRoomMessages(roomId: roomId)) ?? []
messages = loaded.sorted { $0.createdAt().asSecs() < $1.createdAt().asSecs() }
loading = false
bootstrap.markRoomRead(id: roomId)
bootstrap.connectRoom(id: roomId)
}
}
func stop() {
subscriptions.forEach { $0.cancel() }
subscriptions = []
}
func send(_ text: String, appState: AppState) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
if let replyTo = replyingTo, let replyId = replyTo.id() {
appState.bootstrap.sendReplyMessage(roomId: roomId, text: trimmed, replyTo: replyId)
} else {
appState.bootstrap.sendTextMessage(roomId: roomId, text: trimmed)
}
replyingTo = nil
requireScreening = false
}
func sendImage(_ data: Data, contentType: String, appState: AppState) {
appState.bootstrap.sendImageMessage(
roomId: roomId,
file: data.toKotlinByteArray(),
contentType: contentType
)
requireScreening = false
}
}
@@ -0,0 +1,125 @@
import SwiftUI
import Shared
struct MessageBubble: View {
let event: Nostr_sdk_kmpUnsignedEvent
let isMine: Bool
let showImages: Bool
let repliedMessage: Nostr_sdk_kmpUnsignedEvent?
let repliedAuthorName: String?
let onReply: () -> Void
@State private var showTimestamp = false
private var imageUrls: [URL] {
guard showImages else { return [] }
return event.content()
.components(separatedBy: .whitespacesAndNewlines)
.filter { $0.isImageUrl() }
.compactMap { URL(string: $0) }
}
private var text: String {
showImages ? event.content().removeImageUrls() : event.content()
}
var body: some View {
HStack {
if isMine { Spacer(minLength: 40) }
VStack(alignment: isMine ? .trailing : .leading, spacing: 4) {
if let repliedMessage {
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.accentColor)
.frame(width: 3)
VStack(alignment: .leading, spacing: 1) {
Text(repliedAuthorName ?? "Unknown")
.font(.caption.bold())
Text(repliedMessage.content())
.font(.caption)
.lineLimit(2)
.foregroundStyle(.secondary)
}
}
.padding(8)
.background(.black.opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
}
ForEach(imageUrls, id: \.absoluteString) { url in
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFit()
case .failure:
ContentUnavailableView("Image unavailable", systemImage: "photo.badge.exclamationmark")
.frame(height: 120)
default:
ProgressView()
.frame(height: 120)
}
}
.frame(maxWidth: 260)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
if !text.isEmpty {
Text(text)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
isMine ? Color.accentColor : Color(.secondarySystemBackground),
in: BubbleShape(isMine: isMine)
)
.foregroundStyle(isMine ? .white : .primary)
}
if showTimestamp {
Text(event.createdAt().formatAsTime())
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeInOut(duration: 0.15)) {
showTimestamp.toggle()
}
}
.contextMenu {
Button {
UIPasteboard.general.string = event.content()
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
Button {
onReply()
} label: {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
}
if !isMine { Spacer(minLength: 40) }
}
}
}
struct BubbleShape: Shape {
let isMine: Bool
func path(in rect: CGRect) -> Path {
let radius: CGFloat = 16
let tail: CGFloat = 4
let corners: UIRectCorner = isMine
? [.topLeft, .topRight, .bottomLeft]
: [.topLeft, .topRight, .bottomRight]
let path = UIBezierPath(
roundedRect: rect,
byRoundingCorners: corners,
cornerRadii: CGSize(width: radius, height: radius)
)
_ = tail
return Path(path.cgPath)
}
}
@@ -0,0 +1,96 @@
import SwiftUI
import Shared
struct ScreenerCard: View {
@Environment(AppState.self) private var appState
let room: Room
let onAccept: () -> Void
let onReject: () -> Void
@State private var profile: Profile?
@State private var isContact: Bool?
@State private var mutualCount: Int?
@State private var lastActivity: Nostr_sdk_kmpTimestamp?
@State private var subscription: FlowSubscription?
private var member: Nostr_sdk_kmpPublicKey? {
let selfHex = appState.bootstrap.currentPublicKey()?.toHex()
return room.members.first { $0.toHex() != selfHex } ?? room.members.first
}
var body: some View {
VStack(spacing: 16) {
AvatarView(
name: profile?.name ?? member?.short() ?? "?",
picture: profile?.picture,
size: 80
)
Text(profile?.name ?? member?.short() ?? "Unknown")
.font(.headline)
VStack(spacing: 8) {
indicator(
title: "In your contacts",
value: isContact.map { $0 ? "Yes" : "No" },
positive: isContact == true
)
indicator(
title: "Mutual contacts",
value: mutualCount.map { "\($0)" },
positive: (mutualCount ?? 0) > 0
)
indicator(
title: "Last public activity",
value: lastActivity?.humanReadable(),
positive: lastActivity != nil
)
}
.font(.subheadline)
HStack(spacing: 12) {
Button(role: .destructive, action: onReject) {
Text("Reject")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button(action: onAccept) {
Text("Accept")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
}
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 16))
.padding()
.task {
guard let member else { return }
subscription = appState.bootstrap.watchProfile(pubkey: member) { value in
Task { @MainActor in profile = value }
}
isContact = (try? await appState.bootstrap.verifyContact(pubkey: member))?.boolValue
mutualCount = (try? await appState.bootstrap.mutualContacts(pubkey: member))?.count
lastActivity = try? await appState.bootstrap.verifyActivity(pubkey: member)
}
.onDisappear {
subscription?.cancel()
}
}
private func indicator(title: String, value: String?, positive: Bool) -> some View {
HStack {
Image(systemName: positive ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(positive ? .green : .secondary)
Text(title)
Spacer()
if let value {
Text(value).foregroundStyle(.secondary)
} else {
ProgressView().controlSize(.mini)
}
}
}
}
@@ -0,0 +1,35 @@
import SwiftUI
struct AvatarView: View {
let name: String
let picture: String?
var size: CGFloat = 44
var body: some View {
Group {
if let picture, let url = URL(string: picture), !picture.isEmpty {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
placeholder
}
}
} else {
placeholder
}
}
.frame(width: size, height: size)
.clipShape(Circle())
}
private var placeholder: some View {
ZStack {
Circle().fill(Color(.secondarySystemFill))
Text(name.prefix(1).uppercased())
.font(.system(size: size * 0.45, weight: .semibold))
.foregroundStyle(.secondary)
}
}
}
@@ -0,0 +1,129 @@
import SwiftUI
import Shared
struct ContactListView: View {
@Environment(AppState.self) private var appState
@State private var showAddContact = false
@State private var showScanner = false
@State private var newContact = ""
@State private var validating = false
@State private var validationError: String?
@State private var removing: Nostr_sdk_kmpPublicKey?
private var contacts: [Nostr_sdk_kmpPublicKey] {
Array(appState.accountState?.contactList ?? [])
.sorted { $0.toHex() < $1.toHex() }
}
var body: some View {
List {
ForEach(contacts, id: \.self) { pubkey in
Button {
openChat(with: pubkey)
} label: {
ContactRow(pubkey: pubkey)
}
.tint(.primary)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
removing = pubkey
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
.overlay {
if contacts.isEmpty {
ContentUnavailableView(
"No contacts",
systemImage: "person.2",
description: Text("Add contacts to start chatting")
)
}
}
.navigationTitle("Contacts")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
HStack {
Button { showScanner = true } label: {
Image(systemName: "qrcode.viewfinder")
}
Button { showAddContact = true } label: {
Image(systemName: "plus")
}
}
}
}
.sheet(isPresented: $showScanner) {
ScanView { result in
showScanner = false
if let pubkey = appState.bootstrap.parsePublicKey(input: result) {
openChat(with: pubkey)
} else {
appState.errorMessage = "Invalid public key"
}
}
}
.alert("Add Contact", isPresented: $showAddContact) {
TextField("npub or user@domain", text: $newContact)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Cancel", role: .cancel) {
newContact = ""
validationError = nil
}
Button("Add") {
addContact()
}
.disabled(newContact.isEmpty || validating)
} message: {
if let validationError {
Text(validationError)
} else {
Text("Enter a nostr public key (npub) or NIP-05 address")
}
}
.alert("Remove Contact?", isPresented: Binding(
get: { removing != nil },
set: { if !$0 { removing = nil } }
)) {
Button("Cancel", role: .cancel) { removing = nil }
Button("Remove", role: .destructive) {
if let removing {
appState.bootstrap.removeContact(publicKey: removing)
}
removing = nil
}
} message: {
Text("This contact will be removed from your list.")
}
}
private func addContact() {
let value = newContact.trimmingCharacters(in: .whitespacesAndNewlines)
validating = true
Task {
defer { validating = false }
if appState.bootstrap.parsePublicKey(input: value) != nil {
appState.bootstrap.addContact(address: value)
newContact = ""
} else if value.contains("@"),
(try? await appState.bootstrap.searchByAddress(query: value)) != nil {
appState.bootstrap.addContact(address: value)
newContact = ""
} else {
validationError = "Could not find this user. Check the address and try again."
}
}
}
private func openChat(with pubkey: Nostr_sdk_kmpPublicKey) {
do {
let roomId = try appState.bootstrap.createChatRoom(recipients: [pubkey])
appState.path.append(.chat(id: roomId, screening: false))
} catch {
appState.errorMessage = error.localizedDescription
}
}
}
+151
View File
@@ -0,0 +1,151 @@
import SwiftUI
import Shared
struct HomeView: View {
@Environment(AppState.self) private var appState
@State private var showProfileSheet = false
@State private var showScanner = false
@State private var showRelayWarning = false
private var ongoingRooms: [Room] {
appState.chatRooms.filter { $0.kind == RoomKind.ongoing }
}
private var requestRooms: [Room] {
appState.chatRooms.filter { $0.kind == RoomKind.request }
}
private var requestUnread: Int {
requestRooms.reduce(0) { $0 + Int($1.unreadCount) }
}
var body: some View {
List {
if !requestRooms.isEmpty {
Button {
appState.path.append(.requestList)
} label: {
HStack(spacing: 12) {
Image(systemName: "tray.full")
.font(.title2)
.foregroundStyle(.tint)
.frame(width: 44)
VStack(alignment: .leading, spacing: 2) {
Text("New Requests")
.font(.headline)
Text("\(requestRooms.count) request\(requestRooms.count == 1 ? "" : "s")")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
if requestUnread > 0 {
Text("\(requestUnread)")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentColor, in: Capsule())
}
}
}
.tint(.primary)
}
ForEach(ongoingRooms, id: \.id) { room in
Button {
appState.path.append(.chat(id: room.id, screening: false))
} label: {
RoomRow(room: room)
}
.tint(.primary)
}
}
.listStyle(.plain)
.refreshable {
appState.bootstrap.refreshChatRooms()
}
.overlay {
if !appState.partialProcessed {
ProgressView()
} else if appState.chatRooms.isEmpty {
ContentUnavailableView(
"No chats yet",
systemImage: "bubble.left.and.bubble.right",
description: Text("Start a new chat to begin messaging")
)
}
}
.navigationTitle("Coop")
.toolbar {
ToolbarItem(placement: .principal) {
HStack(spacing: 8) {
Text("Coop").font(.headline)
if appState.isSyncing {
ProgressView().controlSize(.small)
}
}
}
ToolbarItem(placement: .topBarLeading) {
Button {
showScanner = true
} label: {
Image(systemName: "qrcode.viewfinder")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showProfileSheet = true
} label: {
AvatarView(
name: appState.currentUserProfile?.name ?? "?",
picture: appState.currentUserProfile?.picture,
size: 32
)
}
}
}
.safeAreaInset(edge: .bottom) {
Button {
appState.path.append(.newChat)
} label: {
Label("New Chat", systemImage: "square.and.pencil")
.font(.headline)
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
.padding(.bottom, 8)
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.trailing)
}
.sheet(isPresented: $showProfileSheet) {
ProfileSheetView()
.presentationDetents([.medium, .large])
}
.sheet(isPresented: $showScanner) {
ScanView { result in
showScanner = false
if let pubkey = appState.bootstrap.parsePublicKey(input: result) {
do {
let roomId = try appState.bootstrap.createChatRoom(recipients: [pubkey])
appState.path.append(.chat(id: roomId, screening: false))
} catch {
appState.errorMessage = error.localizedDescription
}
} else {
appState.errorMessage = "Invalid public key"
}
}
}
.sheet(isPresented: $showRelayWarning) {
RelayWarningSheet()
}
.onChange(of: appState.accountState?.isRelayListEmpty) { _, isEmpty in
showRelayWarning = isEmpty == true
}
}
}
@@ -0,0 +1,90 @@
import SwiftUI
struct ProfileSheetView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
@State private var showLogoutConfirm = false
var body: some View {
NavigationStack {
List {
Section {
HStack(spacing: 16) {
AvatarView(
name: appState.currentUserProfile?.name ?? "?",
picture: appState.currentUserProfile?.picture,
size: 64
)
VStack(alignment: .leading, spacing: 4) {
Text(appState.currentUserProfile?.name ?? "Loading...")
.font(.headline)
if let npub = try? appState.bootstrap.currentPublicKey()?.toBech32() {
Text(npub)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
.padding(.vertical, 4)
Button {
dismiss()
appState.path.append(.myQr)
} label: {
Label("Show QR Code", systemImage: "qrcode")
}
}
Section {
Button {
dismiss()
appState.path.append(.updateProfile)
} label: {
Label("Update Profile", systemImage: "person.crop.circle")
}
Button {
dismiss()
appState.path.append(.contactList)
} label: {
Label("Contact List", systemImage: "person.2")
}
Button {
dismiss()
appState.path.append(.relay)
} label: {
Label("Relay Management", systemImage: "globe")
}
Button {
dismiss()
appState.path.append(.settings)
} label: {
Label("Settings", systemImage: "gear")
}
}
Section {
Button(role: .destructive) {
showLogoutConfirm = true
} label: {
Label("Logout", systemImage: "rectangle.portrait.and.arrow.right")
.foregroundStyle(.red)
}
}
}
.navigationTitle("Profile")
.navigationBarTitleDisplayMode(.inline)
.alert("Logout?", isPresented: $showLogoutConfirm) {
Button("Cancel", role: .cancel) {}
Button("Logout", role: .destructive) {
dismiss()
appState.logout()
}
} message: {
Text("This will delete all local data. Make sure you have backed up your secret key.")
}
}
.tint(.primary)
}
}
@@ -0,0 +1,45 @@
import SwiftUI
struct RelayWarningSheet: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 24) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 48))
.foregroundStyle(.orange)
Text("No Messaging Relays")
.font(.title2.bold())
Text("You don't have any messaging relays configured. You won't be able to receive messages until this is resolved.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
VStack(spacing: 12) {
Button {
appState.bootstrap.refetchMsgRelays()
dismiss()
} label: {
Text("Retry")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button {
appState.bootstrap.useDefaultMsgRelayList()
dismiss()
} label: {
Text("Use Default")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.large)
}
}
.padding(32)
.presentationDetents([.medium])
}
}
@@ -0,0 +1,36 @@
import SwiftUI
import Shared
struct RequestListView: View {
@Environment(AppState.self) private var appState
private var requestRooms: [Room] {
appState.chatRooms.filter { $0.kind == RoomKind.request }
}
var body: some View {
List {
ForEach(requestRooms, id: \.id) { room in
Button {
appState.path.append(.chat(id: room.id, screening: true))
} label: {
RoomRow(room: room)
}
.tint(.primary)
}
}
.refreshable {
appState.bootstrap.refreshChatRooms()
}
.overlay {
if requestRooms.isEmpty {
ContentUnavailableView(
"No requests",
systemImage: "tray",
description: Text("New message requests will appear here")
)
}
}
.navigationTitle("Requests")
}
}
+54
View File
@@ -0,0 +1,54 @@
import SwiftUI
import Shared
struct RoomRow: View {
@Environment(AppState.self) private var appState
let room: Room
@State private var ui: RoomUiState?
@State private var subscription: FlowSubscription?
var body: some View {
HStack(spacing: 12) {
AvatarView(name: ui?.name ?? "?", picture: ui?.picture)
VStack(alignment: .leading, spacing: 2) {
Text(ui?.name ?? "Loading...")
.font(.headline)
.lineLimit(1)
Text(room.lastMessage ?? "")
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text(room.createdAt.ago())
.font(.caption)
.foregroundStyle(.secondary)
if room.unreadCount > 0 {
Text("\(room.unreadCount)")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentColor, in: Capsule())
}
}
}
.padding(.vertical, 4)
.task {
subscription = appState.bootstrap.watchRoomUi(
room: room,
currentUser: appState.bootstrap.currentPublicKey()
) { state in
Task { @MainActor in ui = state }
}
}
.onDisappear {
subscription?.cancel()
subscription = nil
}
}
}
@@ -0,0 +1,190 @@
import SwiftUI
import Shared
struct ContactRow: View {
@Environment(AppState.self) private var appState
let pubkey: Nostr_sdk_kmpPublicKey
@State private var profile: Profile?
@State private var subscription: FlowSubscription?
var body: some View {
HStack(spacing: 12) {
AvatarView(name: profile?.name ?? "?", picture: profile?.picture)
VStack(alignment: .leading, spacing: 2) {
Text(profile?.name ?? "Loading...")
.font(.headline)
.lineLimit(1)
Text(pubkey.short())
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.vertical, 2)
.task {
subscription = appState.bootstrap.watchProfile(pubkey: pubkey) { value in
Task { @MainActor in profile = value }
}
}
.onDisappear {
subscription?.cancel()
subscription = nil
}
}
}
struct NewChatView: View {
@Environment(AppState.self) private var appState
@State private var query = ""
@State private var searchResults: [Nostr_sdk_kmpPublicKey] = []
@State private var searching = false
@State private var selected: [Nostr_sdk_kmpPublicKey] = []
@State private var showScanner = false
@State private var searchTask: Task<Void, Never>?
private var contacts: [Nostr_sdk_kmpPublicKey] {
Array(appState.accountState?.contactList ?? [])
}
var body: some View {
List {
if !selected.isEmpty {
Section("To:") {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(selected, id: \.self) { pubkey in
Button {
selected.removeAll { $0.toHex() == pubkey.toHex() }
} label: {
Label(pubkey.short(), systemImage: "xmark.circle.fill")
.font(.subheadline)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color(.secondarySystemBackground), in: Capsule())
}
.tint(.primary)
}
}
}
}
}
if searching {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
let results = query.isEmpty ? contacts : searchResults
Section(query.isEmpty ? "Contacts" : "Results") {
ForEach(results, id: \.self) { pubkey in
Button {
openChat(with: pubkey)
} label: {
ContactRow(pubkey: pubkey)
}
.tint(.primary)
.swipeActions(edge: .leading) {
Button {
toggleSelection(pubkey)
} label: {
Label("Select", systemImage: "checkmark.circle")
}
.tint(.accentColor)
}
}
}
}
.navigationTitle("New Chat")
.searchable(text: $query, prompt: "npub, user@domain, or name")
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onChange(of: query) { _, newValue in
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
await performSearch(newValue)
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
HStack {
Button {
showScanner = true
} label: {
Image(systemName: "qrcode.viewfinder")
}
if !selected.isEmpty {
Button("Next") {
createGroupChat()
}
}
}
}
}
.sheet(isPresented: $showScanner) {
ScanView { result in
showScanner = false
if let pubkey = appState.bootstrap.parsePublicKey(input: result) {
openChat(with: pubkey)
} else {
appState.errorMessage = "Invalid public key"
}
}
}
}
private func performSearch(_ value: String) async {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count >= 3 else {
searchResults = []
return
}
searching = true
defer { searching = false }
if trimmed.hasPrefix("npub1") {
if let pubkey = appState.bootstrap.parsePublicKey(input: trimmed) {
searchResults = [pubkey]
}
} else if trimmed.contains("@") {
if let pubkey = try? await appState.bootstrap.searchByAddress(query: trimmed) {
searchResults = [pubkey]
} else {
searchResults = []
}
} else {
searchResults = (try? await appState.bootstrap.searchByNostr(query: trimmed)) ?? []
}
}
private func toggleSelection(_ pubkey: Nostr_sdk_kmpPublicKey) {
if let index = selected.firstIndex(where: { $0.toHex() == pubkey.toHex() }) {
selected.remove(at: index)
} else {
selected.append(pubkey)
}
}
private func openChat(with pubkey: Nostr_sdk_kmpPublicKey) {
do {
let roomId = try appState.bootstrap.createChatRoom(recipients: [pubkey])
appState.path.append(.chat(id: roomId, screening: false))
} catch {
appState.errorMessage = error.localizedDescription
}
}
private func createGroupChat() {
do {
let roomId = try appState.bootstrap.createChatRoom(recipients: selected)
appState.path.append(.chat(id: roomId, screening: false))
} catch {
appState.errorMessage = error.localizedDescription
}
}
}
@@ -0,0 +1,67 @@
import SwiftUI
struct ImportView: View {
@Environment(AppState.self) private var appState
@State private var secret = ""
@State private var password = ""
@State private var showScanner = false
private var needsPassword: Bool {
secret.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("ncryptsec1")
}
private var canImport: Bool {
!secret.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
(!needsPassword || !password.isEmpty) &&
appState.accountState?.isImporting != true
}
var body: some View {
Form {
Section {
HStack {
SecureField("nsec1... or bunker://", text: $secret)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button {
showScanner = true
} label: {
Image(systemName: "qrcode.viewfinder")
}
}
if needsPassword {
SecureField("Decrypt Password", text: $password)
}
} header: {
Text("Secret Key")
} footer: {
Text("Enter your nsec, ncryptsec (with password), or bunker:// connection string.")
}
}
.navigationTitle("Import Identity")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Import") {
appState.bootstrap.importIdentity(
secret: secret.trimmingCharacters(in: .whitespacesAndNewlines),
password: needsPassword ? password : nil
)
}
.disabled(!canImport)
}
}
.overlay {
if appState.accountState?.isImporting == true {
ProgressView()
.controlSize(.large)
}
}
.sheet(isPresented: $showScanner) {
ScanView { result in
secret = result
showScanner = false
}
}
}
}
@@ -0,0 +1,20 @@
import SwiftUI
struct NewIdentityView: View {
@Environment(AppState.self) private var appState
var body: some View {
ProfileEditorForm(
title: "Create a new identity",
confirmLabel: "Continue",
isSubmitting: appState.accountState?.isImporting == true
) { name, bio, picture, contentType in
appState.bootstrap.createIdentity(
name: name,
bio: bio,
picture: picture,
contentType: contentType
)
}
}
}
@@ -0,0 +1,69 @@
import SwiftUI
private enum OnboardingRoute: Hashable {
case newIdentity
case importIdentity
}
struct OnboardingView: View {
@State private var path: [OnboardingRoute] = []
var body: some View {
NavigationStack(path: $path) {
VStack(spacing: 32) {
Spacer()
Image(systemName: "bubble.left.and.bubble.right.fill")
.font(.system(size: 80))
.foregroundStyle(.tint)
VStack(spacing: 8) {
Text("Coop")
.font(.largeTitle.bold())
Text("Simple, fast, and reliable nostr messaging")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
Spacer()
VStack(spacing: 12) {
Button {
path.append(.newIdentity)
} label: {
Text("Start Messaging")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button {
path.append(.importIdentity)
} label: {
Text("Add an Existing Identity")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.large)
}
.padding(.horizontal)
Text("By continuing, you agree to our Terms of Service and Privacy Policy")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.padding()
.navigationDestination(for: OnboardingRoute.self) { route in
switch route {
case .newIdentity:
NewIdentityView()
case .importIdentity:
ImportView()
}
}
}
}
}
@@ -0,0 +1,103 @@
import PhotosUI
import SwiftUI
import Shared
struct ProfileEditorForm: View {
let title: String
let confirmLabel: String
let initialName: String
let initialBio: String
let initialPicture: String?
let isSubmitting: Bool
let onConfirm: (String, String?, KotlinByteArray?, String?) -> Void
@State private var name: String
@State private var bio: String
@State private var photoItem: PhotosPickerItem?
@State private var photoData: Data?
@State private var photoContentType: String?
init(
title: String,
confirmLabel: String,
initialName: String = "",
initialBio: String = "",
initialPicture: String? = nil,
isSubmitting: Bool = false,
onConfirm: @escaping (String, String?, KotlinByteArray?, String?) -> Void
) {
self.title = title
self.confirmLabel = confirmLabel
self.initialName = initialName
self.initialBio = initialBio
self.initialPicture = initialPicture
self.isSubmitting = isSubmitting
self.onConfirm = onConfirm
_name = State(initialValue: initialName)
_bio = State(initialValue: initialBio)
}
var body: some View {
Form {
Section {
HStack {
Spacer()
PhotosPicker(selection: $photoItem, matching: .images) {
if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: 120, height: 120)
.clipShape(Circle())
} else {
AvatarView(name: name.isEmpty ? "?" : name, picture: initialPicture, size: 120)
.overlay(alignment: .bottomTrailing) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(.tint)
}
}
}
Spacer()
}
}
.listRowBackground(Color.clear)
Section {
TextField("What others should call you?", text: $name)
TextField("Tell others about yourself (optional)", text: $bio, axis: .vertical)
.lineLimit(3...6)
}
Section {
Button {
onConfirm(
name.trimmingCharacters(in: .whitespacesAndNewlines),
bio.isEmpty ? nil : bio,
photoData?.toKotlinByteArray(),
photoContentType
)
} label: {
HStack {
Spacer()
if isSubmitting {
ProgressView()
} else {
Text(confirmLabel)
}
Spacer()
}
}
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSubmitting)
}
}
.navigationTitle(title)
.task(id: photoItem) {
guard let photoItem else { return }
if let data = try? await photoItem.loadTransferable(type: Data.self) {
photoData = data
photoContentType = photoItem.supportedContentTypes.first?.preferredMIMEType ?? "image/jpeg"
}
}
}
}
@@ -0,0 +1,55 @@
import CoreImage.CIFilterBuiltins
import SwiftUI
struct MyQrView: View {
@Environment(AppState.self) private var appState
private var npub: String? {
try? appState.bootstrap.currentPublicKey()?.toBech32()
}
var body: some View {
VStack(spacing: 24) {
Spacer()
if let npub, let qrImage = generateQrCode(from: npub) {
Image(uiImage: qrImage)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(maxWidth: 280)
.padding()
.background(Color.white, in: RoundedRectangle(cornerRadius: 16))
Text(npub)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
ShareLink(item: npub) {
Label("Share", systemImage: "square.and.arrow.up")
}
.buttonStyle(.bordered)
} else {
ContentUnavailableView("No identity", systemImage: "qrcode")
}
Spacer()
}
.padding()
.navigationTitle("My QR Code")
.navigationBarTitleDisplayMode(.inline)
}
private func generateQrCode(from string: String) -> UIImage? {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(string.utf8)
filter.correctionLevel = "M"
guard let outputImage = filter.outputImage else { return nil }
let scaled = outputImage.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
return UIImage(cgImage: cgImage)
}
}
@@ -0,0 +1,85 @@
import SwiftUI
import Shared
struct ProfileView: View {
@Environment(AppState.self) private var appState
let pubkey: String
@State private var profile: Profile?
@State private var subscription: FlowSubscription?
@State private var parsedKey: Nostr_sdk_kmpPublicKey?
private var record: Nostr_sdk_kmpMetadataRecord? {
profile?.metadata.asRecord()
}
var body: some View {
List {
Section {
VStack(spacing: 12) {
AvatarView(
name: profile?.name ?? "?",
picture: profile?.picture,
size: 120
)
Text(profile?.name ?? "No name")
.font(.title2.bold())
Text(record?.nip05 ?? parsedKey?.short() ?? "")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
}
.listRowBackground(Color.clear)
Section("Details") {
LabeledContent("Username", value: record?.name ?? "None")
LabeledContent("Website", value: record?.website ?? "None")
LabeledContent("Lightning Address", value: record?.lud16 ?? "None")
}
Section {
Button {
openChat()
} label: {
Label("Message", systemImage: "message")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
if let npub = try? parsedKey?.toBech32() {
ShareLink(item: npub) {
Label("Share", systemImage: "square.and.arrow.up")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
.listRowBackground(Color.clear)
}
.navigationTitle("Profile")
.navigationBarTitleDisplayMode(.inline)
.task {
parsedKey = appState.bootstrap.parsePublicKey(input: pubkey)
guard let parsedKey else { return }
subscription = appState.bootstrap.watchProfile(pubkey: parsedKey) { value in
Task { @MainActor in profile = value }
}
}
.onDisappear {
subscription?.cancel()
}
}
private func openChat() {
guard let parsedKey else { return }
do {
let roomId = try appState.bootstrap.createChatRoom(recipients: [parsedKey])
appState.path.append(.chat(id: roomId, screening: false))
} catch {
appState.errorMessage = error.localizedDescription
}
}
}
@@ -0,0 +1,30 @@
import SwiftUI
import Shared
struct UpdateProfileView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
private var record: Nostr_sdk_kmpMetadataRecord? {
appState.currentUserProfile?.metadata.asRecord()
}
var body: some View {
ProfileEditorForm(
title: "Update Profile",
confirmLabel: "Save changes",
initialName: record?.displayName ?? record?.name ?? "",
initialBio: record?.about ?? "",
initialPicture: appState.currentUserProfile?.picture,
isSubmitting: appState.isUpdatingProfile
) { name, bio, picture, contentType in
appState.bootstrap.updateProfile(
name: name,
bio: bio,
picture: picture,
contentType: contentType
)
dismiss()
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import SwiftUI
import VisionKit
struct ScanView: View {
@Environment(\.dismiss) private var dismiss
let onResult: (String) -> Void
var body: some View {
ZStack(alignment: .top) {
if DataScannerViewController.isSupported && DataScannerViewController.isAvailable {
ScannerRepresentable(onResult: onResult)
.ignoresSafeArea()
} else {
ContentUnavailableView(
"Scanner unavailable",
systemImage: "camera.fill",
description: Text("This device does not support the data scanner")
)
}
VStack {
Spacer()
RoundedRectangle(cornerRadius: 16)
.strokeBorder(Color.white, lineWidth: 3)
.frame(width: 250, height: 250)
.background(.clear)
Spacer()
}
.allowsHitTesting(false)
}
.overlay(alignment: .topLeading) {
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title)
.foregroundStyle(.white)
.padding()
}
}
}
}
private struct ScannerRepresentable: UIViewControllerRepresentable {
let onResult: (String) -> Void
func makeUIViewController(context: Context) -> DataScannerViewController {
let scanner = DataScannerViewController(
recognizedDataTypes: [.barcode(symbologies: [.qr])],
qualityLevel: .balanced,
recognizesMultipleItems: false,
isHighFrameRateTrackingEnabled: false,
isHighlightingEnabled: true
)
scanner.delegate = context.coordinator
try? scanner.startScanning()
return scanner
}
func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onResult: onResult)
}
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
let onResult: (String) -> Void
private var handled = false
init(onResult: @escaping (String) -> Void) {
self.onResult = onResult
}
func dataScanner(
_ dataScanner: DataScannerViewController,
didAdd addedItems: [RecognizedItem],
allItems: [RecognizedItem]
) {
guard !handled,
case .barcode(let barcode) = addedItems.first,
let payload = barcode.payloadStringValue
else { return }
handled = true
onResult(payload)
}
}
}
@@ -0,0 +1,169 @@
import SwiftUI
import Shared
private enum RelayRole: String, CaseIterable, Identifiable {
case messaging = "Messaging"
case inbox = "Inbox"
case outbox = "Outbox"
var id: String { rawValue }
}
struct RelayView: View {
@Environment(AppState.self) private var appState
@State private var lists: RelayLists?
@State private var subscription: FlowSubscription?
@State private var showAddRelay = false
@State private var newRelay = ""
@State private var newRelayRole = RelayRole.messaging
@State private var addError: String?
var body: some View {
List {
if let lists {
if !lists.messaging.isEmpty {
Section("Messaging Relays") {
ForEach(lists.messaging, id: \.self) { relay in
relayRow(relay)
.swipeActions(edge: .trailing) {
if lists.messaging.count > 1 {
Button(role: .destructive) {
appState.bootstrap.removeMsgRelay(relay: relay.description())
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
}
}
if !lists.inbox.isEmpty {
Section("Inbox Relays") {
ForEach(lists.inbox, id: \.self) { relay in
relayRow(relay)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
appState.bootstrap.removeRelay(relay: relay.description())
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
}
if !lists.outbox.isEmpty {
Section("Outbox Relays") {
ForEach(lists.outbox, id: \.self) { relay in
relayRow(relay)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
appState.bootstrap.removeRelay(relay: relay.description())
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
}
} else {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
}
.navigationTitle("Relays")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { showAddRelay = true } label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showAddRelay) {
NavigationStack {
Form {
Section("Relay URL") {
TextField("wss://relay.example.com", text: $newRelay)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
}
Section("Role") {
Picker("Role", selection: $newRelayRole) {
ForEach(RelayRole.allCases) { role in
Text(role.rawValue).tag(role)
}
}
.pickerStyle(.inline)
.labelsHidden()
}
if let addError {
Section {
Text(addError).foregroundStyle(.red)
}
}
}
.navigationTitle("Add Relay")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
showAddRelay = false
newRelay = ""
addError = nil
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Add") {
addRelay()
}
.disabled(!newRelay.hasPrefix("wss://"))
}
}
}
.presentationDetents([.medium])
}
.task {
appState.bootstrap.loadRelayLists()
subscription = appState.bootstrap.watchRelayLists { value in
Task { @MainActor in lists = value }
}
}
.onDisappear {
subscription?.cancel()
}
}
private func relayRow(_ relay: Nostr_sdk_kmpRelayUrl) -> some View {
HStack {
Image(systemName: "globe")
.foregroundStyle(.secondary)
Text(relay.description())
.font(.subheadline)
.lineLimit(1)
}
}
private func addRelay() {
let url = newRelay.trimmingCharacters(in: .whitespacesAndNewlines)
guard url.hasPrefix("wss://") else {
addError = "Relay URL must start with wss://"
return
}
switch newRelayRole {
case .messaging:
appState.bootstrap.addMsgRelay(relay: url)
case .inbox:
appState.bootstrap.addInboxRelay(relay: url)
case .outbox:
appState.bootstrap.addOutboxRelay(relay: url)
}
showAddRelay = false
newRelay = ""
addError = nil
}
}
@@ -0,0 +1,71 @@
import SwiftUI
import Shared
struct SettingsView: View {
@Environment(AppState.self) private var appState
@State private var blossomServer = ""
private var settings: Settings? {
appState.settings
}
var body: some View {
Form {
Section("General") {
Toggle("Filter unknown contacts", isOn: Binding(
get: { settings?.screening == true },
set: { appState.bootstrap.setScreening(enabled: $0) }
))
Picker("Media Preview", selection: Binding(
get: { settings?.media ?? MediaConfig.alwaysenabled },
set: { appState.bootstrap.setMediaConfig(media: $0) }
)) {
Text("Disabled").tag(MediaConfig.disabled)
Text("Disabled for Mobile Data").tag(MediaConfig.disabledformobiledata)
Text("Always Enabled").tag(MediaConfig.alwaysenabled)
}
HStack {
Text("Blossom Server")
Spacer()
TextField("https://blossom.band", text: $blossomServer)
.multilineTextAlignment(.trailing)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.foregroundStyle(.secondary)
.onSubmit {
appState.bootstrap.setBlossomServer(
url: blossomServer.isEmpty ? nil : blossomServer
)
}
}
Button {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
} label: {
Label("Notifications", systemImage: "bell")
}
.tint(.primary)
}
Section("Appearance") {
Picker("Theme", selection: Binding(
get: { settings?.theme ?? Theme.system },
set: { appState.bootstrap.setTheme(theme: $0) }
)) {
Text("Light").tag(Theme.light)
Text("Dark").tag(Theme.dark)
Text("System").tag(Theme.system)
}
}
}
.navigationTitle("Settings")
.onAppear {
blossomServer = settings?.blossomServer ?? ""
}
}
}
+14 -1
View File
@@ -2,9 +2,22 @@ import SwiftUI
@main
struct iOSApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView()
.environment(appState)
.onAppear {
appState.start()
NotificationService.shared.requestPermissionIfNeeded()
NotificationService.shared.onOpenChat = { roomId in
appState.path.append(.chat(id: roomId, screening: false))
}
}
.onOpenURL { url in
appState.handle(url)
}
}
}
}
}