add basic ios implementation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user