feat: Implement chat list and search functionality
- Added ChatListViewModel to manage chat list state and server search. - Created ChatRowView for displaying individual chat rows. - Developed SearchView and SearchViewModel for user search functionality. - Introduced MainTabView for tab-based navigation between chats and settings. - Implemented OnboardingPager for onboarding experience. - Created SettingsView and SettingsViewModel for user settings management. - Added SplashView for initial app launch experience.
This commit is contained in:
383
Rosetta/Features/Chats/ChatList/ChatListView.swift
Normal file
383
Rosetta/Features/Chats/ChatList/ChatListView.swift
Normal file
@@ -0,0 +1,383 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - ChatListView
|
||||
|
||||
struct ChatListView: View {
|
||||
@State private var viewModel = ChatListViewModel()
|
||||
@State private var searchText = ""
|
||||
@State private var isSearchPresented = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
RosettaColors.Adaptive.background
|
||||
.ignoresSafeArea()
|
||||
|
||||
chatContent
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbarContent }
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.applyGlassNavBar()
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
isPresented: $isSearchPresented,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Search"
|
||||
)
|
||||
.onChange(of: searchText) { _, newValue in
|
||||
viewModel.setSearchQuery(newValue)
|
||||
}
|
||||
}
|
||||
.tint(RosettaColors.figmaBlue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Glass Nav Bar Modifier
|
||||
|
||||
private struct GlassNavBarModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26, *) {
|
||||
content
|
||||
} else {
|
||||
content
|
||||
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func applyGlassNavBar() -> some View {
|
||||
modifier(GlassNavBarModifier())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chat Content
|
||||
|
||||
private extension ChatListView {
|
||||
var chatContent: some View {
|
||||
List {
|
||||
if viewModel.isLoading {
|
||||
ForEach(0..<8, id: \.self) { _ in
|
||||
ChatRowShimmerView()
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
} else if viewModel.filteredDialogs.isEmpty && !viewModel.showServerResults {
|
||||
emptyState
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
} else {
|
||||
// Local dialog results
|
||||
if !viewModel.pinnedDialogs.isEmpty {
|
||||
pinnedSection
|
||||
}
|
||||
|
||||
ForEach(viewModel.unpinnedDialogs) { dialog in
|
||||
chatRow(dialog)
|
||||
}
|
||||
|
||||
// Server search results
|
||||
if viewModel.showServerResults {
|
||||
serverSearchSection
|
||||
}
|
||||
}
|
||||
|
||||
Color.clear
|
||||
.frame(height: 80)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pinned Section
|
||||
|
||||
private extension ChatListView {
|
||||
var pinnedSection: some View {
|
||||
ForEach(viewModel.pinnedDialogs) { dialog in
|
||||
chatRow(dialog)
|
||||
.listRowBackground(RosettaColors.Adaptive.backgroundSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
func chatRow(_ dialog: Dialog) -> some View {
|
||||
ChatRowView(dialog: dialog)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowSeparator(.visible)
|
||||
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
withAnimation { viewModel.deleteDialog(dialog) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.toggleMute(dialog)
|
||||
} label: {
|
||||
Label(
|
||||
dialog.isMuted ? "Unmute" : "Mute",
|
||||
systemImage: dialog.isMuted ? "bell" : "bell.slash"
|
||||
)
|
||||
}
|
||||
.tint(.indigo)
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
Button {
|
||||
viewModel.markAsRead(dialog)
|
||||
} label: {
|
||||
Label("Read", systemImage: "envelope.open")
|
||||
}
|
||||
.tint(RosettaColors.figmaBlue)
|
||||
|
||||
Button {
|
||||
viewModel.togglePin(dialog)
|
||||
} label: {
|
||||
Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: "pin")
|
||||
}
|
||||
.tint(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
private extension ChatListView {
|
||||
@ToolbarContentBuilder
|
||||
var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
// TODO: Edit mode
|
||||
} label: {
|
||||
Text("Edit")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 4) {
|
||||
storiesAvatars
|
||||
|
||||
Text("Chats")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(RosettaColors.figmaBlue)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
// TODO: Camera
|
||||
} label: {
|
||||
Image(systemName: "camera")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
.accessibilityLabel("Camera")
|
||||
|
||||
Button {
|
||||
// TODO: Compose new message
|
||||
} label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
.accessibilityLabel("New chat")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var storiesAvatars: some View {
|
||||
let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
|
||||
let initials = RosettaColors.initials(name: SessionManager.shared.displayName, publicKey: pk)
|
||||
let colorIdx = RosettaColors.avatarColorIndex(for: pk)
|
||||
|
||||
ZStack {
|
||||
AvatarView(initials: initials, colorIndex: colorIdx, size: 28)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Server Search Results
|
||||
|
||||
private extension ChatListView {
|
||||
@ViewBuilder
|
||||
var serverSearchSection: some View {
|
||||
if viewModel.isServerSearching {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.tint(RosettaColors.Adaptive.textSecondary)
|
||||
Text("Searching users...")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
} else if !viewModel.serverSearchResults.isEmpty {
|
||||
Section {
|
||||
ForEach(viewModel.serverSearchResults, id: \.publicKey) { user in
|
||||
serverSearchRow(user)
|
||||
}
|
||||
} header: {
|
||||
Text("GLOBAL SEARCH")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
} else if viewModel.filteredDialogs.isEmpty {
|
||||
emptyState
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
func serverSearchRow(_ user: SearchUser) -> some View {
|
||||
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey
|
||||
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
|
||||
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
|
||||
|
||||
return Button {
|
||||
// TODO: Navigate to ChatDetailView
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
AvatarView(
|
||||
initials: initials,
|
||||
colorIndex: colorIdx,
|
||||
size: 52,
|
||||
isOnline: user.online == 1,
|
||||
isSavedMessages: isSelf
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Text(isSelf ? "Saved Messages" : (user.title.isEmpty ? String(user.publicKey.prefix(16)) + "..." : user.title))
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
|
||||
if user.verified > 0 {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(RosettaColors.figmaBlue)
|
||||
}
|
||||
}
|
||||
|
||||
if !user.username.isEmpty {
|
||||
Text("@\(user.username)")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if user.online == 1 {
|
||||
Circle()
|
||||
.fill(RosettaColors.online)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
private extension ChatListView {
|
||||
var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: searchText.isEmpty ? "bubble.left.and.bubble.right" : "magnifyingglass")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
|
||||
.padding(.top, 80)
|
||||
|
||||
Text(searchText.isEmpty ? "No chats yet" : "No results for \"\(searchText)\"")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
|
||||
if searchText.isEmpty {
|
||||
Text("Start a conversation by tapping the search tab")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shimmer Row
|
||||
|
||||
private struct ChatRowShimmerView: View {
|
||||
@State private var phase: CGFloat = 0
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 62, height: 62)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 140, height: 14)
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 200, height: 12)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.onAppear {
|
||||
withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
|
||||
phase = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var shimmerGradient: LinearGradient {
|
||||
let baseOpacity = colorScheme == .dark ? 0.06 : 0.08
|
||||
let peakOpacity = colorScheme == .dark ? 0.12 : 0.16
|
||||
return LinearGradient(
|
||||
colors: [
|
||||
Color.gray.opacity(baseOpacity),
|
||||
Color.gray.opacity(peakOpacity),
|
||||
Color.gray.opacity(baseOpacity),
|
||||
],
|
||||
startPoint: UnitPoint(x: phase - 0.4, y: 0),
|
||||
endPoint: UnitPoint(x: phase + 0.4, y: 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
ChatListView()
|
||||
}
|
||||
153
Rosetta/Features/Chats/ChatList/ChatListViewModel.swift
Normal file
153
Rosetta/Features/Chats/ChatList/ChatListViewModel.swift
Normal file
@@ -0,0 +1,153 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - ChatListViewModel
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ChatListViewModel {
|
||||
|
||||
// MARK: - State
|
||||
|
||||
private(set) var isLoading = false
|
||||
private(set) var searchQuery = ""
|
||||
|
||||
// Server search state
|
||||
private(set) var serverSearchResults: [SearchUser] = []
|
||||
private(set) var isServerSearching = false
|
||||
private var searchTask: Task<Void, Never>?
|
||||
private var lastSearchedText = ""
|
||||
|
||||
init() {
|
||||
setupSearchCallback()
|
||||
}
|
||||
|
||||
// MARK: - Computed (local dialog filtering)
|
||||
|
||||
var filteredDialogs: [Dialog] {
|
||||
var result = DialogRepository.shared.sortedDialogs
|
||||
|
||||
let query = searchQuery.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
if !query.isEmpty {
|
||||
result = result.filter {
|
||||
$0.opponentTitle.lowercased().contains(query)
|
||||
|| $0.opponentUsername.lowercased().contains(query)
|
||||
|| $0.lastMessage.lowercased().contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
var pinnedDialogs: [Dialog] {
|
||||
filteredDialogs.filter(\.isPinned)
|
||||
}
|
||||
|
||||
var unpinnedDialogs: [Dialog] {
|
||||
filteredDialogs.filter { !$0.isPinned }
|
||||
}
|
||||
|
||||
var totalUnreadCount: Int {
|
||||
DialogRepository.shared.sortedDialogs
|
||||
.filter { !$0.isMuted }
|
||||
.reduce(0) { $0 + $1.unreadCount }
|
||||
}
|
||||
|
||||
var hasUnread: Bool { totalUnreadCount > 0 }
|
||||
|
||||
/// True when searching and no local results — shows server results section
|
||||
var showServerResults: Bool {
|
||||
let query = searchQuery.trimmingCharacters(in: .whitespaces)
|
||||
return !query.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func setSearchQuery(_ query: String) {
|
||||
searchQuery = query
|
||||
triggerServerSearch()
|
||||
}
|
||||
|
||||
func deleteDialog(_ dialog: Dialog) {
|
||||
DialogRepository.shared.deleteDialog(opponentKey: dialog.opponentKey)
|
||||
}
|
||||
|
||||
func togglePin(_ dialog: Dialog) {
|
||||
DialogRepository.shared.togglePin(opponentKey: dialog.opponentKey)
|
||||
}
|
||||
|
||||
func toggleMute(_ dialog: Dialog) {
|
||||
DialogRepository.shared.toggleMute(opponentKey: dialog.opponentKey)
|
||||
}
|
||||
|
||||
func markAsRead(_ dialog: Dialog) {
|
||||
DialogRepository.shared.markAsRead(opponentKey: dialog.opponentKey)
|
||||
}
|
||||
|
||||
// MARK: - Server Search
|
||||
|
||||
private func triggerServerSearch() {
|
||||
searchTask?.cancel()
|
||||
searchTask = nil
|
||||
|
||||
let trimmed = searchQuery.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty {
|
||||
serverSearchResults = []
|
||||
isServerSearching = false
|
||||
lastSearchedText = ""
|
||||
return
|
||||
}
|
||||
|
||||
if trimmed == lastSearchedText {
|
||||
return
|
||||
}
|
||||
|
||||
isServerSearching = true
|
||||
|
||||
searchTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
|
||||
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
|
||||
guard !currentQuery.isEmpty, currentQuery == trimmed else { return }
|
||||
|
||||
let connState = ProtocolManager.shared.connectionState
|
||||
let hash = SessionManager.shared.privateKeyHash
|
||||
print("[Search] connState=\(connState.rawValue), hasHash=\(hash != nil), query='\(currentQuery)'")
|
||||
|
||||
guard connState == .authenticated, let hash else {
|
||||
print("[Search] NOT AUTHENTICATED - aborting")
|
||||
self.isServerSearching = false
|
||||
return
|
||||
}
|
||||
|
||||
self.lastSearchedText = currentQuery
|
||||
|
||||
var packet = PacketSearch()
|
||||
packet.privateKey = hash
|
||||
packet.search = currentQuery
|
||||
print("[Search] Sending PacketSearch for '\(currentQuery)'")
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupSearchCallback() {
|
||||
print("[Search] Setting up search callback")
|
||||
ProtocolManager.shared.onSearchResult = { [weak self] packet in
|
||||
print("[Search] CALLBACK: received \(packet.users.count) users")
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.serverSearchResults = packet.users
|
||||
self.isServerSearching = false
|
||||
|
||||
for user in packet.users {
|
||||
DialogRepository.shared.updateUserInfo(
|
||||
publicKey: user.publicKey,
|
||||
title: user.title,
|
||||
username: user.username
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
220
Rosetta/Features/Chats/ChatList/ChatRowView.swift
Normal file
220
Rosetta/Features/Chats/ChatList/ChatRowView.swift
Normal file
@@ -0,0 +1,220 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - ChatRowView
|
||||
|
||||
/// Chat row matching Figma spec:
|
||||
/// Row: paddingLeft=10, paddingRight=16, height=78
|
||||
/// Avatar: 62px + 10pt right padding
|
||||
/// Title: SFPro-Medium 17pt, message: SFPro-Regular 15pt
|
||||
/// Time: SFPro-Regular 14pt, subtitle color: #3C3C43/60%
|
||||
struct ChatRowView: View {
|
||||
let dialog: Dialog
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
avatarSection
|
||||
.padding(.trailing, 10)
|
||||
|
||||
contentSection
|
||||
}
|
||||
.padding(.leading, 10)
|
||||
.padding(.trailing, 16)
|
||||
.frame(height: 78)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar
|
||||
|
||||
private extension ChatRowView {
|
||||
var avatarSection: some View {
|
||||
AvatarView(
|
||||
initials: dialog.initials,
|
||||
colorIndex: dialog.avatarColorIndex,
|
||||
size: 62,
|
||||
isOnline: dialog.isOnline,
|
||||
isSavedMessages: dialog.isSavedMessages
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Section
|
||||
|
||||
private extension ChatRowView {
|
||||
var contentSection: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
titleRow
|
||||
Spacer().frame(height: 3)
|
||||
subtitleRow
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Title Row (name + badges + delivery + time)
|
||||
|
||||
private extension ChatRowView {
|
||||
var titleRow: some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle)
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.tracking(-0.43)
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
|
||||
if dialog.isVerified {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(RosettaColors.figmaBlue)
|
||||
}
|
||||
|
||||
if dialog.isMuted {
|
||||
Image(systemName: "speaker.slash.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
if dialog.lastMessageFromMe && !dialog.isSavedMessages {
|
||||
deliveryIcon
|
||||
}
|
||||
|
||||
Text(formattedTime)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(
|
||||
dialog.unreadCount > 0 && !dialog.isMuted
|
||||
? RosettaColors.figmaBlue
|
||||
: RosettaColors.Adaptive.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Row (message + pin + badge)
|
||||
|
||||
private extension ChatRowView {
|
||||
var subtitleRow: some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(messageText)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
if dialog.isPinned && dialog.unreadCount == 0 {
|
||||
Image(systemName: "pin.fill")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.rotationEffect(.degrees(45))
|
||||
}
|
||||
|
||||
if dialog.unreadCount > 0 {
|
||||
unreadBadge
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var messageText: String {
|
||||
if dialog.lastMessage.isEmpty {
|
||||
return "No messages yet"
|
||||
}
|
||||
return dialog.lastMessage
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var deliveryIcon: some View {
|
||||
switch dialog.lastMessageDelivered {
|
||||
case .waiting:
|
||||
Image(systemName: "clock")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
case .delivered:
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
case .read:
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(RosettaColors.figmaBlue)
|
||||
.overlay(alignment: .leading) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(RosettaColors.figmaBlue)
|
||||
.offset(x: -4)
|
||||
}
|
||||
.padding(.trailing, 2)
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
}
|
||||
}
|
||||
|
||||
var unreadBadge: some View {
|
||||
let count = dialog.unreadCount
|
||||
let text = count > 999 ? "\(count / 1000)K" : (count > 99 ? "99+" : "\(count)")
|
||||
let isMuted = dialog.isMuted
|
||||
|
||||
return Text(text)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.frame(minWidth: 20, minHeight: 20)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Time Formatting
|
||||
|
||||
private extension ChatRowView {
|
||||
var formattedTime: String {
|
||||
guard dialog.lastMessageTimestamp > 0 else { return "" }
|
||||
|
||||
let date = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000)
|
||||
let now = Date()
|
||||
let calendar = Calendar.current
|
||||
|
||||
if calendar.isDateInToday(date) {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "h:mm a"
|
||||
return f.string(from: date)
|
||||
} else if calendar.isDateInYesterday(date) {
|
||||
return "Yesterday"
|
||||
} else if let days = calendar.dateComponents([.day], from: date, to: now).day, days < 7 {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "EEE"
|
||||
return f.string(from: date)
|
||||
} else {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "dd.MM.yy"
|
||||
return f.string(from: date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
let sampleDialog = Dialog(
|
||||
id: "preview", account: "mykey", opponentKey: "abc001",
|
||||
opponentTitle: "Alice Johnson",
|
||||
opponentUsername: "alice",
|
||||
lastMessage: "Hey, how are you?",
|
||||
lastMessageTimestamp: Int64(Date().timeIntervalSince1970 * 1000),
|
||||
unreadCount: 3, isOnline: true, lastSeen: 0,
|
||||
isVerified: true, iHaveSent: true,
|
||||
isPinned: false, isMuted: false,
|
||||
lastMessageFromMe: true, lastMessageDelivered: .read
|
||||
)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ChatRowView(dialog: sampleDialog)
|
||||
}
|
||||
.background(RosettaColors.Adaptive.background)
|
||||
}
|
||||
Reference in New Issue
Block a user