Skip to content
This repository was archived by the owner on Jul 13, 2026. It is now read-only.

Commit e423685

Browse files
committed
Align iOS chat screen with topics
1 parent dcfb5ae commit e423685

3 files changed

Lines changed: 329 additions & 5 deletions

File tree

‎ios/Sources/AppModel.swift‎

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,43 @@ final class AppModel: ObservableObject, AppReconciler {
581581
return projection(for: roomId).messages
582582
}
583583

584+
func topics(for roomID: String) -> [AppTopicSummary] {
585+
state?.topics.filter { $0.roomId == roomID && !$0.archived } ?? []
586+
}
587+
588+
func selectedTopic(in roomID: String) -> AppTopicSummary? {
589+
let roomTopics = topics(for: roomID)
590+
if let selectedTopicID = state?.selectedTopicId,
591+
let topic = roomTopics.first(where: { $0.topicId == selectedTopicID })
592+
{
593+
return topic
594+
}
595+
return roomTopics.first
596+
}
597+
598+
func selectedChat(in topic: AppTopicSummary) -> AppChatSummary? {
599+
if let selectedChatID = state?.selectedChatId,
600+
let chat = topic.chats.first(where: { $0.chatId == selectedChatID })
601+
{
602+
return chat
603+
}
604+
if let activeChatID = topic.activeChatId,
605+
let chat = topic.chats.first(where: { $0.chatId == activeChatID })
606+
{
607+
return chat
608+
}
609+
return topic.chats.first
610+
}
611+
612+
func selectedChatRoute(for roomID: String) -> (topicID: String, chatID: String)? {
613+
guard let topic = selectedTopic(in: roomID),
614+
let chat = selectedChat(in: topic)
615+
else {
616+
return nil
617+
}
618+
return (topic.topicId, chat.chatId)
619+
}
620+
584621
var roomListEmptyDescription: String {
585622
if developerErrorText != nil {
586623
return "Open Settings to check connection."
@@ -1023,6 +1060,18 @@ final class AppModel: ObservableObject, AppReconciler {
10231060
}
10241061
}
10251062

1063+
func openTopic(_ topic: AppTopicSummary) {
1064+
dispatchInBackground(.openTopic(roomId: topic.roomId, topicId: topic.topicId))
1065+
}
1066+
1067+
func openChat(_ chat: AppChatSummary, in topic: AppTopicSummary) {
1068+
dispatchInBackground(.openChat(
1069+
roomId: topic.roomId,
1070+
topicId: topic.topicId,
1071+
chatId: chat.chatId
1072+
))
1073+
}
1074+
10261075
func projection(for roomID: String) -> ChatRoomProjection {
10271076
chatProjections[roomID] ?? .empty(roomID: roomID)
10281077
}
@@ -1034,6 +1083,22 @@ final class AppModel: ObservableObject, AppReconciler {
10341083
dispatchInBackground(.createRoom(displayName: name))
10351084
}
10361085

1086+
@discardableResult
1087+
func createTopic(roomID: String, title rawTitle: String) -> Bool {
1088+
let title = rawTitle.trimmingCharacters(in: .whitespacesAndNewlines)
1089+
guard !title.isEmpty else { return false }
1090+
return dispatchInBackground(.createTopic(roomId: roomID, title: title))
1091+
}
1092+
1093+
@discardableResult
1094+
func startChat(in topic: AppTopicSummary) -> Bool {
1095+
dispatchInBackground(.startTopicChat(
1096+
roomId: topic.roomId,
1097+
topicId: topic.topicId,
1098+
reason: nil
1099+
))
1100+
}
1101+
10371102
func createInvite(
10381103
for room: AppRoomSummary,
10391104
onCreated: (@MainActor () -> Void)? = nil
@@ -1405,10 +1470,15 @@ final class AppModel: ObservableObject, AppReconciler {
14051470
guard roomAllowsComposition(roomID) else { return false }
14061471
let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
14071472
guard !text.isEmpty else { return false }
1473+
let selectedRoute = selectedChatRoute(for: roomID)
1474+
let optimisticConversationID = message?.conversationId ?? selectedRoute?.topicID
1475+
let optimisticChatID = message?.chatId ?? selectedRoute?.chatID
14081476
let optimisticMessageID = installOptimisticMessage(
14091477
roomID: roomID,
14101478
text: text,
1411-
replyToMessageID: message?.messageId
1479+
replyToMessageID: message?.messageId,
1480+
conversationID: optimisticConversationID,
1481+
chatID: optimisticChatID
14121482
)
14131483
let action: AppAction
14141484
if let message {
@@ -1417,6 +1487,13 @@ final class AppModel: ObservableObject, AppReconciler {
14171487
text: text,
14181488
replyToMessageId: message.messageId
14191489
)
1490+
} else if let selectedRoute {
1491+
action = .sendChatMessage(
1492+
roomId: roomID,
1493+
topicId: selectedRoute.topicID,
1494+
chatId: selectedRoute.chatID,
1495+
text: text
1496+
)
14201497
} else {
14211498
action = .sendMessage(roomId: roomID, text: text)
14221499
}
@@ -2025,7 +2102,9 @@ final class AppModel: ObservableObject, AppReconciler {
20252102
private func installOptimisticMessage(
20262103
roomID: String,
20272104
text: String,
2028-
replyToMessageID: String?
2105+
replyToMessageID: String?,
2106+
conversationID: String? = nil,
2107+
chatID: String? = nil
20292108
) -> String? {
20302109
guard let state else { return nil }
20312110
optimisticMessageCounter &+= 1
@@ -2037,8 +2116,8 @@ final class AppModel: ObservableObject, AppReconciler {
20372116
roomId: roomID,
20382117
seq: Self.optimisticSequenceBase + sequenceOffset,
20392118
messageId: messageID,
2040-
conversationId: nil,
2041-
chatId: nil,
2119+
conversationId: conversationID,
2120+
chatId: chatID,
20422121
senderAccountId: state.identity.accountId,
20432122
senderDeviceId: state.identity.deviceId,
20442123
senderDisplayName: state.identity.deviceId,

‎ios/Sources/ContentView.swift‎

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1654,6 +1654,140 @@ private struct RoomOptionsSheet: View {
16541654
}
16551655
}
16561656

1657+
private struct TopicChatPickerSheet: View {
1658+
@ObservedObject var model: AppModel
1659+
let roomID: String
1660+
let onClose: () -> Void
1661+
@State private var topicTitle = ""
1662+
1663+
private var room: AppRoomSummary? {
1664+
model.state?.rooms.first(where: { $0.roomId == roomID })
1665+
}
1666+
1667+
private var topics: [AppTopicSummary] {
1668+
model.topics(for: roomID)
1669+
}
1670+
1671+
var body: some View {
1672+
NavigationStack {
1673+
List {
1674+
Section {
1675+
HStack(spacing: 8) {
1676+
TextField("New topic", text: $topicTitle)
1677+
.textInputAutocapitalization(.sentences)
1678+
.submitLabel(.done)
1679+
.onSubmit(createTopic)
1680+
1681+
Button(action: createTopic) {
1682+
Image(systemName: "plus")
1683+
}
1684+
.disabled(topicTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
1685+
.accessibilityLabel("Create topic")
1686+
}
1687+
}
1688+
1689+
if topics.isEmpty {
1690+
ContentUnavailableView("No topics", systemImage: "sidebar.left")
1691+
.listRowSeparator(.hidden)
1692+
} else {
1693+
ForEach(topics, id: \.topicId) { topic in
1694+
Section {
1695+
if topic.chats.isEmpty {
1696+
Button {
1697+
_ = model.startChat(in: topic)
1698+
onClose()
1699+
} label: {
1700+
Label("New chat", systemImage: "square.and.pencil")
1701+
}
1702+
} else {
1703+
ForEach(topic.chats, id: \.chatId) { chat in
1704+
Button {
1705+
model.openChat(chat, in: topic)
1706+
onClose()
1707+
} label: {
1708+
TopicChatRow(
1709+
chat: chat,
1710+
selected: model.state?.selectedChatId == chat.chatId
1711+
&& model.state?.selectedTopicId == topic.topicId
1712+
)
1713+
}
1714+
.buttonStyle(.plain)
1715+
}
1716+
}
1717+
} header: {
1718+
HStack {
1719+
Text(topic.title)
1720+
Spacer()
1721+
Button {
1722+
_ = model.startChat(in: topic)
1723+
onClose()
1724+
} label: {
1725+
Image(systemName: "square.and.pencil")
1726+
}
1727+
.accessibilityLabel("New chat in \(topic.title)")
1728+
}
1729+
}
1730+
}
1731+
}
1732+
}
1733+
.navigationTitle(room?.displayName ?? "Chats")
1734+
.navigationBarTitleDisplayMode(.inline)
1735+
.toolbar {
1736+
ToolbarItem(placement: .topBarTrailing) {
1737+
Button("Done", action: onClose)
1738+
}
1739+
}
1740+
}
1741+
}
1742+
1743+
private func createTopic() {
1744+
let title = topicTitle.trimmingCharacters(in: .whitespacesAndNewlines)
1745+
guard !title.isEmpty else { return }
1746+
if model.createTopic(roomID: roomID, title: title) {
1747+
topicTitle = ""
1748+
}
1749+
}
1750+
}
1751+
1752+
private struct TopicChatRow: View {
1753+
let chat: AppChatSummary
1754+
let selected: Bool
1755+
1756+
var body: some View {
1757+
HStack(spacing: 10) {
1758+
Image(systemName: selected ? "checkmark.circle.fill" : "bubble.left")
1759+
.foregroundStyle(selected ? Color.accentColor : Color.secondary)
1760+
.frame(width: 22)
1761+
1762+
VStack(alignment: .leading, spacing: 3) {
1763+
Text(chat.title)
1764+
.font(.body)
1765+
.foregroundStyle(.primary)
1766+
.lineLimit(1)
1767+
1768+
if !chat.lastMessagePreview.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
1769+
Text(chat.lastMessagePreview)
1770+
.font(.caption)
1771+
.foregroundStyle(.secondary)
1772+
.lineLimit(1)
1773+
}
1774+
}
1775+
1776+
Spacer()
1777+
1778+
if chat.unreadCount > 0 {
1779+
Text("\(chat.unreadCount)")
1780+
.font(.caption2.weight(.semibold))
1781+
.foregroundStyle(.white)
1782+
.padding(.horizontal, 7)
1783+
.padding(.vertical, 3)
1784+
.background(Capsule().fill(Color.accentColor))
1785+
}
1786+
}
1787+
.contentShape(Rectangle())
1788+
}
1789+
}
1790+
16571791
private struct RoomThreadView: View {
16581792
@ObservedObject var model: AppModel
16591793
@ObservedObject var people: NostrPeopleModel
@@ -1673,6 +1807,7 @@ private struct RoomThreadView: View {
16731807
@State private var showMediaGallery = false
16741808
@State private var showRoomDetails = false
16751809
@State private var showRoomOptions = false
1810+
@State private var showChatPicker = false
16761811
@State private var showAddPeople = false
16771812
@State private var composerText = ""
16781813
@State private var selectedPhotoItems: [PhotosPickerItem] = []
@@ -1691,6 +1826,28 @@ private struct RoomThreadView: View {
16911826
model.projection(for: roomID)
16921827
}
16931828

1829+
private var selectedTopic: AppTopicSummary? {
1830+
model.selectedTopic(in: roomID)
1831+
}
1832+
1833+
private var selectedChat: AppChatSummary? {
1834+
selectedTopic.flatMap { model.selectedChat(in: $0) }
1835+
}
1836+
1837+
private var chatNavigationTitle: String {
1838+
if let title = selectedChat?.title.trimmingCharacters(in: .whitespacesAndNewlines),
1839+
!title.isEmpty
1840+
{
1841+
return title
1842+
}
1843+
if let title = selectedTopic?.title.trimmingCharacters(in: .whitespacesAndNewlines),
1844+
!title.isEmpty
1845+
{
1846+
return title
1847+
}
1848+
return room?.displayName ?? "Chat"
1849+
}
1850+
16941851
private var mediaGalleryItems: [ChatMediaGalleryItem] {
16951852
guard let gallery = model.state?.mediaGallery,
16961853
gallery.roomId == roomID
@@ -1777,10 +1934,20 @@ private struct RoomThreadView: View {
17771934
}
17781935
}
17791936
.background(Color(.systemGroupedBackground).ignoresSafeArea())
1780-
.navigationTitle(room?.displayName ?? "Chat")
1937+
.navigationTitle(chatNavigationTitle)
17811938
.navigationBarTitleDisplayMode(.inline)
17821939
.chatNavigationBarChrome()
17831940
.toolbar {
1941+
ToolbarItem(placement: .topBarLeading) {
1942+
Button {
1943+
showChatPicker = true
1944+
} label: {
1945+
Image(systemName: "line.3.horizontal")
1946+
}
1947+
.accessibilityLabel("Chats")
1948+
.accessibilityIdentifier("ChatPickerButton")
1949+
}
1950+
17841951
if let room, room.state == .connected {
17851952
ToolbarItem(placement: .topBarTrailing) {
17861953
Button {
@@ -1826,6 +1993,12 @@ private struct RoomThreadView: View {
18261993
)
18271994
.presentationDetents([.medium])
18281995
}
1996+
.sheet(isPresented: $showChatPicker) {
1997+
TopicChatPickerSheet(model: model, roomID: roomID) {
1998+
showChatPicker = false
1999+
}
2000+
.presentationDetents([.medium, .large])
2001+
}
18292002
.sheet(isPresented: $showAddPeople) {
18302003
if let room {
18312004
ChatPeoplePickerSheet(model: model, people: people, existingRoom: room) { _ in }

0 commit comments

Comments
 (0)