Author SHA1 Message Date
reya b6f4a97778 feat: add support for reaction (#52)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/52
2026-09-07 13:30:42 +00:00
39 changed files with 372 additions and 2897 deletions
@@ -5,9 +5,13 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -34,6 +38,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import rust.nostr.sdk.EventId
import rust.nostr.sdk.PublicKey
@@ -46,6 +51,13 @@ import su.reya.coop.formatAsTime
import su.reya.coop.isImageUrl
import su.reya.coop.removeImageUrls
@Immutable
data class ReactionGroup(
val emoji: String,
val authors: List<PublicKey>,
val containsMe: Boolean
)
@Immutable
data class MessageModel(
val id: EventId,
@@ -54,15 +66,20 @@ data class MessageModel(
val images: List<String>,
val timestamp: String,
val isMine: Boolean,
val replyEventIds: List<EventId>
val replyEventIds: List<EventId>,
val reactions: List<ReactionGroup> = emptyList()
)
@Composable
fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel {
fun rememberMessageModel(
event: UnsignedEvent,
reactions: List<UnsignedEvent> = emptyList(),
currentUser: PublicKey? = null
): MessageModel {
val settings = LocalSettings.current
val isMobileData = LocalConnectivity.current
return remember(event, currentUser, settings, isMobileData) {
return remember(event, reactions, currentUser, settings, isMobileData) {
val id = event.ensureId().id()!!
val isMine = currentUser == event.author()
val content = event.content()
@@ -104,6 +121,16 @@ fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null):
append(cleanedContent.substring(lastIndex))
}
val groupedReactions = reactions.groupBy { it.content() }
.map { (emoji, events) ->
val authors = events.map { it.author() }
ReactionGroup(
emoji = emoji,
authors = authors,
containsMe = authors.any { it == currentUser }
)
}
MessageModel(
id = id,
author = event.author(),
@@ -111,7 +138,8 @@ fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null):
images = images,
timestamp = event.createdAt().formatAsTime(),
isMine = isMine,
replyEventIds = replyEventIds
replyEventIds = replyEventIds,
reactions = groupedReactions
)
}
}
@@ -157,6 +185,15 @@ fun ChatMessage(
),
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(contentAlignment = Alignment.BottomEnd) {
Column(
modifier = Modifier.padding(
bottom = if (model.reactions.isNotEmpty()) 4.dp else 0.dp,
end = if (model.reactions.isNotEmpty() && !model.isMine) 8.dp else 0.dp
),
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
if (model.annotatedContent.isNotBlank()) {
Surface(
@@ -188,16 +225,69 @@ fun ChatMessage(
)
}
}
}
if (model.reactions.isNotEmpty()) {
MessageReactions(
reactions = model.reactions,
modifier = Modifier.offset(y = 12.dp)
)
}
}
if (isMessageClicked) {
Text(
text = model.timestamp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.align(
if (model.isMine) Alignment.End else Alignment.Start
)
modifier = Modifier.padding(top = if (model.reactions.isNotEmpty()) 8.dp else 0.dp)
)
}
}
}
}
@Composable
private fun MessageReactions(
reactions: List<ReactionGroup>,
modifier: Modifier = Modifier
) {
val totalCount = reactions.sumOf { it.authors.size }
val displayEmojis = reactions.take(3).map { it.emoji }
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
displayEmojis.forEach { emoji ->
Surface(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.surface,
shape = CircleShape,
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = emoji,
fontSize = 12.sp,
)
}
}
}
if (totalCount > 2) {
Surface(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.surface,
shape = CircleShape,
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = totalCount.toString(),
style = MaterialTheme.typography.labelSmall,
fontSize = 10.sp,
)
}
}
}
}
}
@@ -80,6 +80,7 @@ import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
@@ -91,6 +92,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.EventId
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileCache
@@ -144,10 +146,21 @@ fun ChatScreen(
val loading = viewModel.loading
val newOtherMessages = viewModel.newOtherMessages
val requireScreening = viewModel.requireScreening
val messages = viewModel.messages
val allEvents = viewModel.messages
val displayMessages by remember {
derivedStateOf { allEvents.filter { it.kind().asStd() != KindStandard.REACTION } }
}
val reactionsByMessage by remember {
derivedStateOf {
allEvents.filter { it.kind().asStd() == KindStandard.REACTION }
.groupBy { it.tags().eventIds().firstOrNull() }
}
}
val groupedMessages =
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
remember { derivedStateOf { displayMessages.groupBy { it.createdAt().formatAsGroup() } } }
val roomState by remember(id, currentUser?.publicKey) {
(room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
@@ -170,7 +183,7 @@ fun ChatScreen(
for (group in groupedMessages.value) {
val msgInGroup = group.value
val idx = msgInGroup.indexOfFirst { it.id() == eventId }
val idx = msgInGroup.indexOfFirst { it.ensureId().id() == eventId }
if (idx != -1) {
targetIndex = currentIndex + idx
break
@@ -214,8 +227,8 @@ fun ChatScreen(
}
}
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
LaunchedEffect(allEvents.size) {
if (displayMessages.isNotEmpty()) {
listState.animateScrollToItem(0)
}
}
@@ -293,7 +306,7 @@ fun ChatScreen(
room?.let { ScreenerCard(accountViewModel, it) }
}
when (messages.isNotEmpty()) {
when (displayMessages.isNotEmpty()) {
true -> {
LazyColumn(
modifier = Modifier
@@ -308,14 +321,15 @@ fun ChatScreen(
items = messagesInGroup,
key = { it.ensureId().id()?.toHex()!! }
) { event ->
val msgReactions = reactionsByMessage[event.id()] ?: emptyList()
val model =
rememberMessageModel(event, currentUser?.publicKey)
rememberMessageModel(event, msgReactions, currentUser?.publicKey)
val replyPreview =
remember(model.replyEventIds, messages.size) {
remember(model.replyEventIds, displayMessages.size) {
model.replyEventIds.firstOrNull()
?.let { replyId ->
messages.find { it.id() == replyId }
displayMessages.find { it.ensureId().id() == replyId }
}
}
@@ -464,14 +478,11 @@ fun ChatScreen(
val (model, bounds) = selectedMessage ?: return@AnimatedVisibility
val density = LocalDensity.current
val windowInfo = LocalWindowInfo.current
val windowHeight = windowInfo.containerSize.height
val scrollState = rememberScrollState()
var menuHeight by remember { mutableFloatStateOf(0f) }
val spacing = with(density) { 12.dp.toPx() }
val showAbove =
(windowHeight - bounds.bottom) < (menuHeight + spacing) && bounds.top > (menuHeight + spacing)
var toolbarHeight by remember { mutableFloatStateOf(0f) }
val spacing = with(density) { 6.dp.toPx() }
Box(
modifier = Modifier
@@ -480,11 +491,30 @@ fun ChatScreen(
.clickable { selectedMessage = null }
.verticalScroll(scrollState),
) {
val totalExtraHeight = if (menuHeight > 0) menuHeight + spacing else 300f
val totalExtraHeight = (if (menuHeight > 0) menuHeight + spacing else 300f) +
(if (toolbarHeight > 0) toolbarHeight + spacing else 100f)
val contentBottom = with(density) { (bounds.bottom + totalExtraHeight).toDp() }
Spacer(modifier = Modifier.height(contentBottom + 200.dp))
// Reaction Toolbar (Above)
Box(
modifier = Modifier
.offset { IntOffset(0, (bounds.top - toolbarHeight - spacing).toInt().coerceAtLeast(0)) }
.onGloballyPositioned { toolbarHeight = it.size.height.toFloat() }
.fillMaxWidth()
.padding(horizontal = 16.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
ReactionToolbar(
onReaction = { reaction ->
viewModel.sendReaction(model.id, reaction)
selectedMessage = null
}
)
}
// Message Preview
ChatMessage(
model = model,
modifier = Modifier
@@ -492,21 +522,17 @@ fun ChatScreen(
.padding(horizontal = 16.dp)
)
val menuOffset = if (showAbove) {
bounds.top - menuHeight - spacing
} else {
bounds.bottom + spacing
}
// Action Menu (Below)
Box(
modifier = Modifier
.offset { IntOffset(0, menuOffset.toInt().coerceAtLeast(0)) }
.offset { IntOffset(0, (bounds.bottom + spacing).toInt()) }
.onGloballyPositioned { menuHeight = it.size.height.toFloat() }
.fillMaxWidth()
.padding(horizontal = 16.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
ContextMenu { action ->
ContextMenu(
onAction = { action ->
when (action) {
"Copy" -> {
scope.launch {
@@ -524,6 +550,7 @@ fun ChatScreen(
}
selectedMessage = null
}
)
}
}
}
@@ -622,9 +649,41 @@ private fun ReplyPreview(
}
}
@Composable
private fun ReactionToolbar(
onReaction: (String) -> Unit
) {
val reactionEmojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉")
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(24.dp),
shadowElevation = 1.dp
) {
Row(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
reactionEmojis.forEach { emoji ->
Text(
text = emoji,
modifier = Modifier
.clickable { onReaction(emoji) }
.padding(4.dp),
fontSize = 24.sp
)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ContextMenu(onAction: (String) -> Unit) {
private fun ContextMenu(
onAction: (String) -> Unit
) {
val menuItems = listOf(
"Copy" to Res.drawable.ic_copy,
"Reply" to Res.drawable.ic_reply
@@ -633,6 +692,8 @@ private fun ContextMenu(onAction: (String) -> Unit) {
DropdownMenuGroup(
shapes = MenuDefaults.groupShape(1, 1),
containerColor = MenuDefaults.groupVibrantContainerColor,
tonalElevation = 1.dp,
shadowElevation = 1.dp,
modifier = Modifier.width(220.dp)
) {
val itemCount = menuItems.size
-3
View File
@@ -5,6 +5,3 @@ PRODUCT_BUNDLE_IDENTIFIER=su.reya.coop.Coop$(TEAM_ID)
CURRENT_PROJECT_VERSION=1
MARKETING_VERSION=1.0
FRAMEWORK_SEARCH_PATHS=$(inherited) "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"
OTHER_LDFLAGS=$(inherited) -framework Shared
-14
View File
@@ -1,14 +0,0 @@
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)
}
-103
View File
@@ -1,103 +0,0 @@
import Foundation
import Shared
import UIKit
@MainActor
@Observable
final class AppState {
let bootstrap: Bootstrap
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 = Bootstrap.companion.create(storage: KeychainStorage())
}
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 = []
}
func resume() {
Task { try? await bootstrap.resume() }
}
func pause() {
let taskId = UIApplication.shared.beginBackgroundTask()
Task {
try? await bootstrap.pause()
UIApplication.shared.endBackgroundTask(taskId)
}
}
private func handleNewMessage(_ event: Nostr_sdk_kmpUnsignedEvent) {
NotificationService.shared.notifyNewMessage(roomId: event.roomId(), content: event.content())
}
}
+19 -67
View File
@@ -2,80 +2,32 @@ import SwiftUI
import Shared
struct ContentView: View {
@Environment(AppState.self) private var appState
@State private var showContent = false
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)
}
}
}
}
.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
VStack {
Button("Click me!") {
withAnimation {
showContent = !showContent
}
}
@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)
if showContent {
VStack(spacing: 16) {
Image(systemName: "swift")
.font(.system(size: 200))
.foregroundColor(.accentColor)
Text("SwiftUI: \(Greeting().greet())")
}
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding()
}
}
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)
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
-13
View File
@@ -4,18 +4,5 @@
<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>
@@ -1,73 +0,0 @@
import Foundation
import Security
import Shared
final class KeychainStorage: 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)
}
}
}
@@ -1,26 +0,0 @@
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)
}
}
@@ -1,24 +0,0 @@
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()
}
}
@@ -1,56 +0,0 @@
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)
}
}
}
}
-288
View File
@@ -1,288 +0,0 @@
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 authorProfiles: [String: Profile] = [:]
private var showScreener: Bool {
(viewModel?.requireScreening ?? false) && appState.settings?.screening == true
}
private var isGroup: Bool {
viewModel?.room?.isGroup() == 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
}
}
.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: 30
)
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: 2) {
ForEach(groupedMessages, id: \.0) { group, messages in
Text(headerTitle(group: group, first: messages.first))
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 14)
ForEach(Array(messages.enumerated()), id: \.element.stableId) { index, event in
messageCell(event: event, at: index, in: messages)
}
}
if let last = viewModel?.messages.last, isMine(last) {
Text("Delivered")
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.top, 2)
}
}
.padding(.horizontal, 12)
}
.defaultScrollAnchor(.bottom)
.scrollDismissesKeyboard(.interactively)
.overlay {
if viewModel?.loading == true {
ProgressView()
}
}
}
@ViewBuilder
private func messageCell(
event: Nostr_sdk_kmpUnsignedEvent,
at index: Int,
in messages: [Nostr_sdk_kmpUnsignedEvent]
) -> some View {
let mine = isMine(event)
let authorHex = event.author().toHex()
let nextIsSameAuthor = index + 1 < messages.count &&
messages[index + 1].author().toHex() == authorHex
let prevIsSameAuthor = index > 0 &&
messages[index - 1].author().toHex() == authorHex
let replyId = event.tags().eventIds().first
let replied = replyId.flatMap { id in
viewModel?.messages.first { $0.id()?.toHex() == id.toHex() }
}
MessageBubble(
event: event,
isMine: mine,
showImages: showImages,
isFirstOfRun: !prevIsSameAuthor,
isLastOfRun: !nextIsSameAuthor,
showAuthorName: isGroup && !mine && !prevIsSameAuthor,
showAuthorAvatar: isGroup && !mine,
authorName: authorName(for: event),
authorPicture: authorProfiles[authorHex]?.picture,
repliedMessage: replied,
repliedAuthorName: replied.flatMap { authorName(for: $0) },
onReply: { viewModel?.replyingTo = event }
)
.padding(.bottom, nextIsSameAuthor ? 0 : 8)
}
private var inputBar: some View {
VStack(spacing: 0) {
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, 16)
.padding(.vertical, 8)
.background(.bar)
}
HStack(alignment: .bottom, spacing: 10) {
PhotosPicker(selection: $photoItem, matching: .images) {
Image(systemName: "plus")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(.secondary)
.frame(width: 34, height: 34)
.background(Color(.secondarySystemFill), in: Circle())
}
TextField("Message", text: $input, axis: .vertical)
.lineLimit(1...6)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background {
Capsule()
.strokeBorder(Color(.systemGray4), lineWidth: 1)
}
if !input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
viewModel?.send(input, appState: appState)
input = ""
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 32))
.foregroundStyle(Color(.systemBlue))
}
.transition(.scale.combined(with: .opacity))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.bar)
.animation(.snappy(duration: 0.2), value: input.isEmpty)
}
}
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 func headerTitle(group: String, first: Nostr_sdk_kmpUnsignedEvent?) -> String {
guard let first else { return group }
return "\(group) \(first.createdAt().formatAsTime())"
}
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 isMine(_ event: Nostr_sdk_kmpUnsignedEvent) -> Bool {
event.author().toHex() == appState.bootstrap.currentPublicKey()?.toHex()
}
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 profile = authorProfiles[hex] {
return profile.name
}
loadAuthorProfile(pubkey: event.author(), hex: hex)
return event.author().short()
}
private func loadAuthorProfile(pubkey: Nostr_sdk_kmpPublicKey, hex: String) {
guard authorProfiles[hex] == nil else { return }
let sub = appState.bootstrap.watchProfile(pubkey: pubkey) { profile in
Task { @MainActor in
if let profile {
authorProfiles[hex] = profile
}
}
}
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)"
}
}
@@ -1,82 +0,0 @@
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
}
}
@@ -1,177 +0,0 @@
import SwiftUI
import Shared
struct MessageBubble: View {
let event: Nostr_sdk_kmpUnsignedEvent
let isMine: Bool
let showImages: Bool
let isFirstOfRun: Bool
let isLastOfRun: Bool
let showAuthorName: Bool
let showAuthorAvatar: Bool
let authorName: String?
let authorPicture: String?
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(alignment: .bottom, spacing: 6) {
if isMine {
Spacer(minLength: 48)
} else {
avatarGutter
}
VStack(alignment: isMine ? .trailing : .leading, spacing: 2) {
if showAuthorName, let authorName {
Text(authorName)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 16)
}
if let repliedMessage {
replyPreview(repliedMessage)
}
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: 14, style: .continuous))
}
if !text.isEmpty {
Text(text)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.foregroundStyle(isMine ? .white : .primary)
.background(
isMine ? Color(.systemBlue) : Color(.systemGray5),
in: BubbleShape(isMine: isMine, tail: isLastOfRun)
)
}
if showTimestamp {
Text(event.createdAt().formatAsTime())
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 16)
}
}
if !isMine { Spacer(minLength: 48) }
}
.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")
}
}
}
@ViewBuilder
private var avatarGutter: some View {
if isLastOfRun, showAuthorAvatar {
AvatarView(name: authorName ?? "?", picture: authorPicture, size: 26)
} else {
Color.clear.frame(width: 26, height: 26)
}
}
private func replyPreview(_ replied: Nostr_sdk_kmpUnsignedEvent) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(repliedAuthorName ?? "Unknown")
.font(.caption2.bold())
Text(replied.content())
.font(.caption)
.lineLimit(2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
isMine ? Color(.systemBlue).opacity(0.7) : Color(.systemGray4),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.foregroundStyle(isMine ? .white : .primary)
.padding(.horizontal, 4)
}
}
struct BubbleShape: Shape {
let isMine: Bool
let tail: Bool
func path(in rect: CGRect) -> Path {
let radius: CGFloat = 18
let small: CGFloat = tail ? 4 : radius
let topLeft: CGFloat = radius
let topRight: CGFloat = radius
let bottomLeft: CGFloat = isMine ? radius : small
let bottomRight: CGFloat = isMine ? small : radius
var path = Path()
path.move(to: CGPoint(x: rect.minX + topLeft, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - topRight, y: rect.minY))
path.addArc(
center: CGPoint(x: rect.maxX - topRight, y: rect.minY + topRight),
radius: topRight, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false
)
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - bottomRight))
path.addArc(
center: CGPoint(x: rect.maxX - bottomRight, y: rect.maxY - bottomRight),
radius: bottomRight, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false
)
path.addLine(to: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY))
path.addArc(
center: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY - bottomLeft),
radius: bottomLeft, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false
)
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + topLeft))
path.addArc(
center: CGPoint(x: rect.minX + topLeft, y: rect.minY + topLeft),
radius: topLeft, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false
)
path.closeSubpath()
return path
}
}
@@ -1,96 +0,0 @@
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)
}
}
}
}
@@ -1,35 +0,0 @@
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)
}
}
}
@@ -1,129 +0,0 @@
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
}
}
}
-118
View File
@@ -1,118 +0,0 @@
import SwiftUI
import Shared
struct HomeView: View {
@Environment(AppState.self) private var appState
@State private var showProfileSheet = 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: 10) {
Circle()
.fill(requestUnread > 0 ? Color.accentColor : .clear)
.frame(width: 10, height: 10)
Image(systemName: "tray.full")
.font(.title2)
.foregroundStyle(.secondary)
.frame(width: 48, height: 48)
.background(Color(.secondarySystemFill), in: Circle())
VStack(alignment: .leading, spacing: 3) {
Text("Message Requests")
.font(.body.weight(requestUnread > 0 ? .semibold : .regular))
Text("\(requestRooms.count)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(Color(.systemGray3))
}
.padding(.vertical, 4)
}
.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 Messages",
systemImage: "bubble.left.and.bubble.right",
description: Text("Start a new conversation")
)
}
}
.navigationTitle("Coop")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
showProfileSheet = true
} label: {
AvatarView(
name: appState.currentUserProfile?.name ?? "?",
picture: appState.currentUserProfile?.picture,
size: 30
)
}
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
if appState.isSyncing {
ProgressView().controlSize(.small)
}
Button {
appState.path.append(.newChat)
} label: {
Image(systemName: "square.and.pencil")
}
}
}
}
.sheet(isPresented: $showProfileSheet) {
ProfileSheetView()
.presentationDetents([.medium, .large])
}
.sheet(isPresented: $showRelayWarning) {
RelayWarningSheet()
}
.onChange(of: appState.accountState?.isRelayListEmpty) { _, isEmpty in
showRelayWarning = isEmpty == true
}
}
}
@@ -1,90 +0,0 @@
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)
}
}
@@ -1,45 +0,0 @@
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])
}
}
@@ -1,36 +0,0 @@
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")
}
}
-57
View File
@@ -1,57 +0,0 @@
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?
private var unread: Bool { room.unreadCount > 0 }
var body: some View {
HStack(spacing: 10) {
Circle()
.fill(unread ? Color.accentColor : .clear)
.frame(width: 10, height: 10)
AvatarView(name: ui?.name ?? "?", picture: ui?.picture, size: 48)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline) {
Text(ui?.name ?? "Loading...")
.font(.body.weight(unread ? .semibold : .regular))
.lineLimit(1)
Spacer()
Text(room.createdAt.ago())
.font(.subheadline)
.foregroundStyle(.secondary)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(Color(.systemGray3))
}
Text(room.lastMessage ?? "")
.font(.subheadline.weight(unread ? .semibold : .regular))
.foregroundStyle(unread ? .primary : .secondary)
.lineLimit(2)
}
}
.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
}
}
}
@@ -1,190 +0,0 @@
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
}
}
}
@@ -1,67 +0,0 @@
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
}
}
}
}
@@ -1,20 +0,0 @@
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
)
}
}
}
@@ -1,69 +0,0 @@
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()
}
}
}
}
}
@@ -1,103 +0,0 @@
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"
}
}
}
}
@@ -1,55 +0,0 @@
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)
}
}
@@ -1,85 +0,0 @@
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
}
}
}
@@ -1,30 +0,0 @@
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
@@ -1,87 +0,0 @@
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)
}
}
}
@@ -1,169 +0,0 @@
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
}
}
@@ -1,71 +0,0 @@
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 ?? ""
}
}
}
-24
View File
@@ -2,33 +2,9 @@ import SwiftUI
@main
struct iOSApp: App {
@State private var appState = AppState()
@Environment(\.scenePhase) private var scenePhase
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)
}
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active:
appState.resume()
case .background:
appState.pause()
default:
break
}
}
}
}
}
-1
View File
@@ -22,7 +22,6 @@ kotlin {
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
binaryOptions["objcExportSuspendFunctionLaunchThreadRestriction"] = "none"
}
}
@@ -144,12 +144,15 @@ class MessageManager(private val nostr: Nostr) {
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
try {
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
val kValue = if (isReaction) "reaction" else "dm"
// Construct reference tags
val tags = listOf(
Tag.identifier(giftId.toHex()),
Tag.publicKey(rumor.author()),
Tag.custom("r", listOf(rumor.roomId().toString())),
Tag.custom("k", listOf("14"))
Tag.custom("k", listOf("14", kValue))
)
// Set event kind
@@ -176,12 +179,13 @@ class MessageManager(private val nostr: Nostr) {
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
val kTag = SingleLetterTag.lowercase(Alphabet.K)
// Get all DM events
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
// Get all rumors (DMs and Reactions)
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm", "reaction"))
val events = client?.database()?.query(filter)?.toVec() ?: return null
// Collect rooms
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
val lastDmTimestampMap: MutableMap<Long, ULong> = mutableMapOf()
events
.map { UnsignedEvent.fromJson(it.content()) }
@@ -189,18 +193,41 @@ class MessageManager(private val nostr: Nostr) {
.forEach { rumor ->
val id = rumor.roomId()
val isFromMe = rumor.author() == userPubkey
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
val existing = roomsMap[id]
val createdAt = rumor.createdAt()
// If the room is new or the current rumor is newer than the existing one
if (existing == null || createdAt.asSecs() > existing.createdAt.asSecs()) {
// A room is "Ongoing" if it was already marked as such or if the current rumor is from the user
val isOngoing = (existing?.kind == RoomKind.Ongoing) || isFromMe
if (existing == null) {
val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id)
roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room
} else if (isFromMe && existing.kind != RoomKind.Ongoing) {
// If it's an older rumor but sent by the user, mark the room as Ongoing
roomsMap[id] = existing.copy(kind = RoomKind.Ongoing)
// If the first event we see is a reaction, don't use it as lastMessage
roomsMap[id] = if (isReaction) {
room.copy(lastMessage = null)
} else {
lastDmTimestampMap[id] = createdAt.asSecs()
room
}
if (isFromMe) {
roomsMap[id] = roomsMap[id]!!.copy(kind = RoomKind.Ongoing)
}
} else {
// Update the overall room timestamp (for sorting) if this event is newer
if (createdAt.asSecs() > existing.createdAt.asSecs()) {
roomsMap[id] = roomsMap[id]!!.copy(createdAt = createdAt)
}
// Update the last message content if this is a DM and it's newer than the last DM we've seen
if (!isReaction) {
val lastDmTs = lastDmTimestampMap[id] ?: 0uL
if (createdAt.asSecs() >= lastDmTs) {
lastDmTimestampMap[id] = createdAt.asSecs()
roomsMap[id] = roomsMap[id]!!.copy(lastMessage = rumor.content())
}
}
// If any event is from the user, mark the room as Ongoing
if (isFromMe && roomsMap[id]?.kind != RoomKind.Ongoing) {
roomsMap[id] = roomsMap[id]!!.copy(kind = RoomKind.Ongoing)
}
}
}
@@ -348,4 +375,64 @@ class MessageManager(private val nostr: Nostr) {
throw IllegalStateException("Failed to send message: ${e.message}", e)
}
}
suspend fun sendReaction(
to: Set<PublicKey>,
targetEventId: EventId,
reaction: String,
onRumorCreated: ((UnsignedEvent) -> Unit)? = null,
) {
try {
val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val tags = mutableListOf<Tag>()
tags.add(Tag.event(targetEventId))
// Add public key tags for each recipient (including me) to ensure roomId consistency
to.forEach { pubkey ->
tags.add(Tag.publicKey(pubkey))
}
for (receiver in setOf(currentUser) + to) {
// Construct the rumor event
val rumor = EventBuilder(Kind.fromStd(KindStandard.REACTION), reaction)
.tags(tags)
.finalizeUnsigned(currentUser)
.ensureId()
// Emit the rumor to the chat screen
if (receiver == currentUser) {
onRumorCreated?.invoke(rumor)
}
// Construct the gift wrap event
val gift = nip59MakeGiftWrapAsync(
signer = signer,
receiverPubkey = receiver,
rumor = rumor,
extraTags = listOf(
Tag.custom("k", listOf("14"))
)
)
// Send the event to receiver's NIP-17 relays
val output = client?.sendEvent(
event = gift,
target = SendEventTarget.toNip17(),
ackPolicy = AckPolicy.none(),
authenticationTimeout = Duration.parse("2s")
)
if (output != null) {
// Keep track of rumor IDs
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
rumorMap[id] = output.id
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Failed to send reaction: ${e.message}", e)
}
}
}
@@ -241,8 +241,29 @@ class ChatRepository(
}
}
fun sendReaction(roomId: Long, targetEventId: EventId, reaction: String) {
scope.launch(defaultDispatcher) {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.messages.sendReaction(
to = room.members,
targetEventId = targetEventId,
reaction = reaction,
onRumorCreated = {
scope.launch(defaultDispatcher) {
updateRoomState(it, roomId)
}
},
)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun updateRoomState(event: UnsignedEvent, roomId: Long = event.roomId()) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
val isReaction = event.kind().asStd() == KindStandard.REACTION
_state.update { currentState ->
val rooms = currentState.rooms.toMutableMap()
@@ -256,22 +277,24 @@ class ChatRepository(
// New room discovery
val newRoom = Room.new(event, currentUser, roomId).copy(
kind = newKind,
unreadCount = if (isFromMe) 0 else 1
unreadCount = if (isFromMe || isReaction) 0 else 1,
lastMessage = if (isReaction) null else event.content()
)
rooms[newRoom.id] = newRoom
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
// Only update preview if message is newer (handles sync/late arrivals)
// Update timestamp for any newer event (DM or Reaction)
// But only update preview if it's a DM
rooms[roomId] = existingRoom.copy(
lastMessage = event.content(),
lastMessage = if (isReaction) existingRoom.lastMessage else event.content(),
createdAt = event.createdAt(),
kind = newKind,
unreadCount = if (isFromMe) existingRoom.unreadCount else existingRoom.unreadCount + 1
unreadCount = if (isFromMe || isReaction) existingRoom.unreadCount else existingRoom.unreadCount + 1
)
} else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) {
// Even if it's an older message, if it's from me, the room is ongoing
// Even if it's an older message or reaction, if it's from me, the room is ongoing
rooms[roomId] = existingRoom.copy(kind = RoomKind.Ongoing)
} else {
// Don't update the room list state for older messages
// Don't update the room list state for older messages or reactions that don't change preview
return@update currentState
}
currentState.copy(rooms = rooms)
@@ -74,4 +74,8 @@ class ChatScreenViewModel(
fun sendFileMessage(file: ByteArray?, type: String?) {
chatRepository.sendFileMessage(id, file, type)
}
fun sendReaction(targetEventId: EventId, reaction: String) {
chatRepository.sendReaction(id, targetEventId, reaction)
}
}
@@ -1,306 +0,0 @@
package su.reya.coop
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Filter
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.NostrManager
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.AccountState
import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.MediaRepository
import su.reya.coop.repository.SettingsRepository
import su.reya.coop.viewmodel.ProfileCache
import kotlin.coroutines.resume
class FlowSubscription(private val job: Job) {
fun cancel() {
job.cancel()
}
}
data class RelayLists(
val messaging: List<RelayUrl>,
val inbox: List<RelayUrl>,
val outbox: List<RelayUrl>,
)
class Bootstrap private constructor(
val scope: CoroutineScope,
val nostr: Nostr,
val settingsRepository: SettingsRepository,
val accountRepository: AccountRepository,
val chatRepository: ChatRepository,
val profileCache: ProfileCache,
) {
companion object {
fun create(storage: AppStorage): Bootstrap {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val nostr = NostrManager.instance
val settingsRepository = SettingsRepository(storage, scope)
val mediaRepository = MediaRepository(settingsRepository)
val accountRepository = AccountRepository(
nostr = nostr,
storage = storage,
mediaRepository = mediaRepository,
settingsRepository = settingsRepository,
scope = scope,
externalSignerHandler = null,
)
val chatRepository = ChatRepository(nostr, mediaRepository, settingsRepository, scope)
val profileCache = ProfileCache(nostr)
return Bootstrap(
scope = scope,
nostr = nostr,
settingsRepository = settingsRepository,
accountRepository = accountRepository,
chatRepository = chatRepository,
profileCache = profileCache,
)
}
}
private var notificationsJob: Job? = null
private var dbPath: String? = null
private var onNewMessage: ((UnsignedEvent) -> Unit)? = null
fun start(dbPath: String, onNewMessage: (UnsignedEvent) -> Unit) {
this.dbPath = dbPath
this.onNewMessage = onNewMessage
startNotificationLoop()
}
private fun startNotificationLoop() {
if (notificationsJob?.isActive == true) return
val path = dbPath ?: return
val messageCallback = onNewMessage ?: return
notificationsJob = scope.launch {
runCatching {
nostr.init(path)
nostr.connectBootstrapRelays()
nostr.handleNotifications(
onMetadataUpdate = { pubkey, metadata ->
scope.launch { nostr.profiles.emitMetadataUpdate(pubkey, metadata) }
},
onContactListUpdate = { contacts ->
scope.launch { nostr.profiles.emitContactListUpdate(contacts) }
},
onNewMessage = { event ->
nostr.emitNewEvent(event)
messageCallback(event)
},
)
}.onFailure {
accountRepository.showError("Failed to start Nostr: ${it.message}")
}
}
}
suspend fun resume() {
startNotificationLoop()
nostr.waitUntilInitialized()
nostr.client?.connect()
val pubkey = nostr.signer.publicKeyFlow.value ?: return
runCatching { nostr.profiles.getUserMetadata() }
val relays = nostr.relays.getMsgRelays(pubkey)
if (relays.isEmpty()) return
relays.forEach { relay ->
nostr.client?.addRelay(relay)
nostr.client?.connectRelay(relay)
}
nostr.messages.updateSyncState { it.copy(isSyncing = true) }
val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(pubkey)
val target = relays.associateWith { listOf(filter) }
nostr.client?.subscribe(target = ReqTarget.manual(target), id = "gift-wraps")
}
suspend fun pause() {
nostr.client?.disconnect()
}
private fun <T> Flow<T>.watch(onEach: (T) -> Unit): FlowSubscription {
val job = scope.launch(Dispatchers.Main) { collect { onEach(it) } }
return FlowSubscription(job)
}
fun watchAccountState(onEach: (AccountState) -> Unit): FlowSubscription =
accountRepository.state.watch(onEach)
fun watchIsUpdatingProfile(onEach: (Boolean) -> Unit): FlowSubscription =
accountRepository.isUpdatingProfile.watch(onEach)
fun watchCurrentUserProfile(onEach: (Profile?) -> Unit): FlowSubscription =
accountRepository.currentUserProfile.watch(onEach)
fun watchChatRooms(onEach: (List<Room>) -> Unit): FlowSubscription =
chatRepository.chatRooms.watch(onEach)
fun watchIsSyncing(onEach: (Boolean) -> Unit): FlowSubscription =
chatRepository.isSyncing.watch(onEach)
fun watchPartialProcessed(onEach: (Boolean) -> Unit): FlowSubscription =
chatRepository.isPartialProcessedGiftWrap.watch(onEach)
fun watchSettings(onEach: (Settings) -> Unit): FlowSubscription =
settingsRepository.settings.watch(onEach)
fun watchNewEvents(onEach: (UnsignedEvent) -> Unit): FlowSubscription =
chatRepository.newEvents.watch(onEach)
fun watchErrors(onEach: (String) -> Unit): FlowSubscription =
merge(
accountRepository.errorEvents,
chatRepository.errorEvents,
profileCache.errorEvents,
).watch(onEach)
fun watchProfile(pubkey: PublicKey, onEach: (Profile?) -> Unit): FlowSubscription =
profileCache.getMetadata(pubkey).watch(onEach)
fun watchRoomUi(
room: Room,
currentUser: PublicKey?,
onEach: (RoomUiState) -> Unit
): FlowSubscription =
room.uiStateFlow(profileCache, currentUser).watch(onEach)
@Throws(IllegalArgumentException::class)
fun createChatRoom(recipients: List<PublicKey>): Long =
chatRepository.createChatRoom(recipients)
fun getChatRoom(id: Long): Room? = chatRepository.getChatRoom(id)
fun markRoomRead(id: Long) = chatRepository.markAsRead(id)
fun refreshChatRooms() = chatRepository.refreshChatRooms()
fun connectRoom(id: Long) = chatRepository.chatRoomConnect(id)
fun sendTextMessage(roomId: Long, text: String) =
chatRepository.sendMessage(roomId, text, emptyList())
fun sendReplyMessage(roomId: Long, text: String, replyTo: EventId) =
chatRepository.sendMessage(roomId, text, listOf(replyTo))
fun sendImageMessage(roomId: Long, file: ByteArray, contentType: String) =
chatRepository.sendFileMessage(roomId, file, contentType, emptyList())
suspend fun loadRoomMessages(roomId: Long): List<UnsignedEvent> =
suspendCancellableCoroutine { cont ->
chatRepository.loadChatRoomMessages(roomId) { cont.resume(it) }
}
fun importIdentity(secret: String, password: String?) =
accountRepository.importIdentity(secret, password)
fun createIdentity(name: String, bio: String?, picture: ByteArray?, contentType: String?) =
accountRepository.createIdentity(name, bio, picture, contentType)
fun updateProfile(name: String?, bio: String?, picture: ByteArray?, contentType: String?) =
accountRepository.updateProfile(name, bio, picture, contentType)
fun logout() = accountRepository.logout {}
fun dismissNotificationBanner() = accountRepository.dismissNotificationBanner()
fun addContact(address: String) = accountRepository.addContact(address)
fun removeContact(publicKey: PublicKey) = accountRepository.removeContact(publicKey)
suspend fun searchByAddress(query: String): PublicKey? =
suspendCancellableCoroutine { cont ->
accountRepository.searchByAddress(query) { cont.resume(it) }
}
suspend fun searchByNostr(query: String): List<PublicKey> =
suspendCancellableCoroutine { cont ->
accountRepository.searchByNostr(query) { cont.resume(it) }
}
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? =
suspendCancellableCoroutine { cont ->
accountRepository.verifyActivity(pubkey) { cont.resume(it) }
}
suspend fun verifyContact(pubkey: PublicKey): Boolean =
suspendCancellableCoroutine { cont ->
accountRepository.verifyContact(pubkey) { cont.resume(it) }
}
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> =
suspendCancellableCoroutine { cont ->
accountRepository.mutualContacts(pubkey) { cont.resume(it) }
}
fun refetchMsgRelays() = accountRepository.refetchMsgRelays()
fun useDefaultMsgRelayList() = accountRepository.useDefaultMsgRelayList()
fun loadRelayLists() {
accountRepository.loadCurrentUserRelayList()
accountRepository.loadCurrentUserMsgRelayList()
}
fun watchRelayLists(onEach: (RelayLists) -> Unit): FlowSubscription =
accountRepository.state.map { state ->
RelayLists(
messaging = state.userMsgRelayList,
inbox = state.userRelayList
.filter { it.value == RelayMetadata.READ || it.value == null }
.keys.toList(),
outbox = state.userRelayList
.filter { it.value == RelayMetadata.WRITE || it.value == null }
.keys.toList(),
)
}.watch(onEach)
fun addMsgRelay(relay: String) = accountRepository.addMsgRelay(relay)
fun removeMsgRelay(relay: String) = accountRepository.removeMsgRelay(relay)
fun addInboxRelay(relay: String) = accountRepository.addInboxRelay(relay)
fun addOutboxRelay(relay: String) = accountRepository.addOutboxRelay(relay)
fun removeRelay(relay: String) = accountRepository.removeRelay(relay)
fun setTheme(theme: Theme) = settingsRepository.update { it.copy(theme = theme) }
fun setMediaConfig(media: MediaConfig) = settingsRepository.update { it.copy(media = media) }
fun setScreening(enabled: Boolean) = settingsRepository.update { it.copy(screening = enabled) }
fun setBlossomServer(url: String?) = settingsRepository.update { it.copy(blossomServer = url) }
fun parsePublicKey(input: String): PublicKey? =
runCatching { PublicKey.parse(input.trim()) }.getOrNull()
fun currentPublicKey(): PublicKey? = nostr.signer.publicKeyFlow.value
fun resetState() {
accountRepository.resetInternalState()
chatRepository.resetInternalState()
}
}