mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 15:45:20 +00:00
Align DM sheet toolbar with people list (#795)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -177,7 +177,41 @@
|
|||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "bitchat"
|
"value": "bitchat"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"%@ active": {
|
||||||
|
"comment": "A label at the bottom of the people list sheet showing the number of active users.",
|
||||||
|
"localizations": {
|
||||||
|
"ar": { "stringUnit": { "state": "translated", "value": "%@ نشطين" } },
|
||||||
|
"bn": { "stringUnit": { "state": "translated", "value": "%@ সক্রিয়" } },
|
||||||
|
"de": { "stringUnit": { "state": "translated", "value": "%@ aktiv" } },
|
||||||
|
"en": { "stringUnit": { "state": "translated", "value": "%@ active" } },
|
||||||
|
"es": { "stringUnit": { "state": "translated", "value": "%@ activos" } },
|
||||||
|
"fil": { "stringUnit": { "state": "translated", "value": "%@ aktibo" } },
|
||||||
|
"fr": { "stringUnit": { "state": "translated", "value": "%@ actifs" } },
|
||||||
|
"he": { "stringUnit": { "state": "translated", "value": "%@ פעילים" } },
|
||||||
|
"hi": { "stringUnit": { "state": "translated", "value": "%@ सक्रिय" } },
|
||||||
|
"id": { "stringUnit": { "state": "translated", "value": "%@ aktif" } },
|
||||||
|
"it": { "stringUnit": { "state": "translated", "value": "%@ attivi" } },
|
||||||
|
"ja": { "stringUnit": { "state": "translated", "value": "%@ 人がアクティブ" } },
|
||||||
|
"ko": { "stringUnit": { "state": "translated", "value": "활성 사용자 %@명" } },
|
||||||
|
"ms": { "stringUnit": { "state": "translated", "value": "%@ aktif" } },
|
||||||
|
"ne": { "stringUnit": { "state": "translated", "value": "%@ सक्रिय" } },
|
||||||
|
"nl": { "stringUnit": { "state": "translated", "value": "%@ actief" } },
|
||||||
|
"pl": { "stringUnit": { "state": "translated", "value": "%@ aktywni" } },
|
||||||
|
"pt": { "stringUnit": { "state": "translated", "value": "%@ ativos" } },
|
||||||
|
"pt-BR": { "stringUnit": { "state": "translated", "value": "%@ ativos" } },
|
||||||
|
"ru": { "stringUnit": { "state": "translated", "value": "%@ активных" } },
|
||||||
|
"sv": { "stringUnit": { "state": "translated", "value": "%@ aktiva" } },
|
||||||
|
"ta": { "stringUnit": { "state": "translated", "value": "%@ செயலில்" } },
|
||||||
|
"th": { "stringUnit": { "state": "translated", "value": "%@ กำลังใช้งาน" } },
|
||||||
|
"tr": { "stringUnit": { "state": "translated", "value": "%@ aktif" } },
|
||||||
|
"uk": { "stringUnit": { "state": "translated", "value": "%@ активних" } },
|
||||||
|
"ur": { "stringUnit": { "state": "translated", "value": "%@ فعال" } },
|
||||||
|
"vi": { "stringUnit": { "state": "translated", "value": "%@ đang hoạt động" } },
|
||||||
|
"zh-Hans": { "stringUnit": { "state": "translated", "value": "活跃用户 %@ 名" } },
|
||||||
|
"zh-Hant": { "stringUnit": { "state": "translated", "value": "活躍使用者 %@ 位" } }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"app_info.close": {
|
"app_info.close": {
|
||||||
|
|||||||
+358
-436
@@ -33,12 +33,9 @@ struct ContentView: View {
|
|||||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
@State private var showPeerList = false
|
@State private var showPeerList = false
|
||||||
@State private var showSidebar = false
|
@State private var showSidebar = false
|
||||||
@State private var sidebarDragOffset: CGFloat = 0
|
|
||||||
@State private var showAppInfo = false
|
@State private var showAppInfo = false
|
||||||
@State private var showCommandSuggestions = false
|
@State private var showCommandSuggestions = false
|
||||||
@State private var commandSuggestions: [String] = []
|
@State private var commandSuggestions: [String] = []
|
||||||
@State private var backSwipeOffset: CGFloat = 0
|
|
||||||
@State private var showPrivateChat = false
|
|
||||||
@State private var showMessageActions = false
|
@State private var showMessageActions = false
|
||||||
@State private var selectedMessageSender: String?
|
@State private var selectedMessageSender: String?
|
||||||
@State private var selectedMessageSenderID: String?
|
@State private var selectedMessageSenderID: String?
|
||||||
@@ -80,7 +77,37 @@ struct ContentView: View {
|
|||||||
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Body
|
private var peopleSheetTitle: String {
|
||||||
|
String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var peopleSheetSubtitle: String? {
|
||||||
|
switch locationManager.selectedChannel {
|
||||||
|
case .mesh:
|
||||||
|
return "#mesh"
|
||||||
|
case .location(let channel):
|
||||||
|
return "#\(channel.geohash.lowercased())"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var peopleSheetActiveCount: Int {
|
||||||
|
switch locationManager.selectedChannel {
|
||||||
|
case .mesh:
|
||||||
|
return viewModel.allPeers.filter { $0.peerID != viewModel.meshService.myPeerID }.count
|
||||||
|
case .location:
|
||||||
|
return viewModel.visibleGeohashPeople().count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private struct PrivateHeaderContext {
|
||||||
|
let headerPeerID: String
|
||||||
|
let peer: BitchatPeer?
|
||||||
|
let displayName: String
|
||||||
|
let isNostrAvailable: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -92,116 +119,17 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// Messages area with overlays
|
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ZStack {
|
VStack(spacing: 0) {
|
||||||
// Base layer - public messages
|
|
||||||
messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic)
|
messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
// Private chat slide-over (full screen with own header/input)
|
|
||||||
if viewModel.selectedPrivateChatPeer != nil {
|
|
||||||
privateChatView
|
|
||||||
.frame(width: geometry.size.width)
|
|
||||||
.background(backgroundColor)
|
|
||||||
.transition(.asymmetric(
|
|
||||||
insertion: .move(edge: .trailing),
|
|
||||||
removal: .move(edge: .trailing)
|
|
||||||
))
|
|
||||||
.offset(x: showPrivateChat ? -1 : max(0, geometry.size.width))
|
|
||||||
.offset(x: backSwipeOffset.isNaN ? 0 : backSwipeOffset)
|
|
||||||
.gesture(
|
|
||||||
DragGesture()
|
|
||||||
.onChanged { value in
|
|
||||||
if value.translation.width > 0 && !value.translation.width.isNaN {
|
|
||||||
let maxWidth = max(0, geometry.size.width)
|
|
||||||
backSwipeOffset = min(value.translation.width, maxWidth.isNaN ? 0 : maxWidth)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEnded { value in
|
|
||||||
let translation = value.translation.width.isNaN ? 0 : value.translation.width
|
|
||||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
|
||||||
if translation > TransportConfig.uiBackSwipeTranslationLarge || (translation > TransportConfig.uiBackSwipeTranslationSmall && velocity > TransportConfig.uiBackSwipeVelocityThreshold) {
|
|
||||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
|
||||||
showPrivateChat = false
|
|
||||||
backSwipeOffset = 0
|
|
||||||
viewModel.endPrivateChat()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationShortSeconds)) {
|
|
||||||
backSwipeOffset = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right edge swipe zone for easier sidebar opening (iOS-native behavior)
|
|
||||||
if !showSidebar && viewModel.selectedPrivateChatPeer == nil {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Color.clear
|
|
||||||
.frame(width: 20)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.gesture(
|
|
||||||
DragGesture(minimumDistance: 5)
|
|
||||||
.onChanged { value in
|
|
||||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
|
||||||
if translationWidth < 0 {
|
|
||||||
let newOffset = max(translationWidth, -300)
|
|
||||||
sidebarDragOffset = newOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEnded { value in
|
|
||||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
|
||||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
|
||||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
|
||||||
if translationWidth < -50 || velocity < -300 {
|
|
||||||
showSidebar = true
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
} else {
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.allowsHitTesting(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sidebar overlay (only over messages area, not input)
|
|
||||||
if viewModel.selectedPrivateChatPeer == nil {
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
// Tap to dismiss area
|
|
||||||
Color.clear
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
|
||||||
showSidebar = false
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always render sidebar to avoid layout recalculation during drag
|
|
||||||
sidebarView
|
|
||||||
#if os(macOS)
|
|
||||||
.frame(width: min(300, max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.4))
|
|
||||||
#else
|
|
||||||
.frame(width: max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.7)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
.offset(x: {
|
|
||||||
let dragOffset = sidebarDragOffset.isNaN ? 0 : sidebarDragOffset
|
|
||||||
let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width)
|
|
||||||
return showSidebar ? dragOffset : width + dragOffset
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// Input always accessible below sidebar (only in public chat)
|
|
||||||
if viewModel.selectedPrivateChatPeer == nil {
|
if viewModel.selectedPrivateChatPeer == nil {
|
||||||
inputView
|
inputView
|
||||||
}
|
}
|
||||||
@@ -212,10 +140,27 @@ struct ContentView: View {
|
|||||||
.frame(minWidth: 600, minHeight: 400)
|
.frame(minWidth: 600, minHeight: 400)
|
||||||
#endif
|
#endif
|
||||||
.onChange(of: viewModel.selectedPrivateChatPeer) { newValue in
|
.onChange(of: viewModel.selectedPrivateChatPeer) { newValue in
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
if newValue != nil {
|
||||||
showPrivateChat = newValue != nil
|
showSidebar = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { showSidebar || viewModel.selectedPrivateChatPeer != nil },
|
||||||
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
showSidebar = false
|
||||||
|
viewModel.endPrivateChat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
peopleSheetView
|
||||||
|
#if os(iOS)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
.sheet(isPresented: $showAppInfo) {
|
.sheet(isPresented: $showAppInfo) {
|
||||||
AppInfoView()
|
AppInfoView()
|
||||||
.onAppear { viewModel.isAppInfoPresented = true }
|
.onAppear { viewModel.isAppInfoPresented = true }
|
||||||
@@ -252,8 +197,7 @@ struct ContentView: View {
|
|||||||
viewModel.startPrivateChat(with: peerID)
|
viewModel.startPrivateChat(with: peerID)
|
||||||
}
|
}
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
showSidebar = false
|
showSidebar = true
|
||||||
sidebarDragOffset = 0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -889,23 +833,33 @@ struct ContentView: View {
|
|||||||
messageText = ""
|
messageText = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sidebar View
|
// MARK: - Sheet Content
|
||||||
|
|
||||||
private var sidebarView: some View {
|
private var peopleSheetView: some View {
|
||||||
HStack(spacing: 0) {
|
Group {
|
||||||
// Grey vertical bar for visual continuity
|
if viewModel.selectedPrivateChatPeer != nil {
|
||||||
Rectangle()
|
privateChatSheetView
|
||||||
.fill(Color.gray.opacity(0.3))
|
} else {
|
||||||
.frame(width: 1)
|
peopleListSheetView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
#if os(macOS)
|
||||||
|
.frame(minWidth: 420, minHeight: 520)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
// MARK: - People Sheet Views
|
||||||
// Header - match main toolbar height
|
|
||||||
HStack {
|
private var peopleListSheetView: some View {
|
||||||
Text("content.header.people")
|
VStack(spacing: 0) {
|
||||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(peopleSheetTitle)
|
||||||
|
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
Spacer()
|
Spacer()
|
||||||
// Show QR in mesh on all platforms
|
|
||||||
if case .mesh = locationManager.selectedChannel {
|
if case .mesh = locationManager.selectedChannel {
|
||||||
Button(action: { showVerifySheet = true }) {
|
Button(action: { showVerifySheet = true }) {
|
||||||
Image(systemName: "qrcode")
|
Image(systemName: "qrcode")
|
||||||
@@ -916,113 +870,304 @@ struct ContentView: View {
|
|||||||
String(localized: "content.help.verification", comment: "Help text for verification button")
|
String(localized: "content.help.verification", comment: "Help text for verification button")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Button(action: {
|
||||||
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
|
viewModel.endPrivateChat()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Close")
|
||||||
}
|
}
|
||||||
.frame(height: headerHeight) // Match header height
|
let activeText = String.localizedStringWithFormat(
|
||||||
.padding(.horizontal, 12)
|
String(localized: "%@ active", comment: "Count of active users in the people sheet"),
|
||||||
.background(backgroundColor.opacity(0.95))
|
"\(peopleSheetActiveCount)"
|
||||||
|
)
|
||||||
|
|
||||||
Divider()
|
if let subtitle = peopleSheetSubtitle {
|
||||||
|
let subtitleColor: Color = {
|
||||||
|
switch locationManager.selectedChannel {
|
||||||
|
case .mesh:
|
||||||
|
return Color.blue
|
||||||
|
case .location:
|
||||||
|
return Color.green
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(subtitle)
|
||||||
|
.foregroundColor(subtitleColor)
|
||||||
|
Text(activeText)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
} else {
|
||||||
|
Text(activeText)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.background(backgroundColor)
|
||||||
|
|
||||||
// Rooms and People list
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
// People section
|
if case .location = locationManager.selectedChannel {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
GeohashPeopleList(
|
||||||
if case .location = locationManager.selectedChannel {
|
viewModel: viewModel,
|
||||||
GeohashPeopleList(viewModel: viewModel,
|
textColor: textColor,
|
||||||
textColor: textColor,
|
secondaryTextColor: secondaryTextColor,
|
||||||
secondaryTextColor: secondaryTextColor,
|
onTapPerson: {
|
||||||
onTapPerson: {
|
showSidebar = true
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
}
|
||||||
showSidebar = false
|
)
|
||||||
sidebarDragOffset = 0
|
} else {
|
||||||
}
|
MeshPeerList(
|
||||||
})
|
viewModel: viewModel,
|
||||||
} else {
|
textColor: textColor,
|
||||||
MeshPeerList(viewModel: viewModel,
|
secondaryTextColor: secondaryTextColor,
|
||||||
textColor: textColor,
|
onTapPeer: { peerID in
|
||||||
secondaryTextColor: secondaryTextColor,
|
viewModel.startPrivateChat(with: peerID)
|
||||||
onTapPeer: { peerID in
|
showSidebar = true
|
||||||
viewModel.startPrivateChat(with: peerID)
|
},
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
onToggleFavorite: { peerID in
|
||||||
showSidebar = false
|
viewModel.toggleFavorite(peerID: peerID)
|
||||||
sidebarDragOffset = 0
|
},
|
||||||
}
|
onShowFingerprint: { peerID in
|
||||||
},
|
viewModel.showFingerprint(for: peerID)
|
||||||
onToggleFavorite: { peerID in
|
}
|
||||||
viewModel.toggleFavorite(peerID: peerID)
|
)
|
||||||
},
|
|
||||||
onShowFingerprint: { peerID in
|
|
||||||
viewModel.showFingerprint(for: peerID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
.id(viewModel.allPeers.map { "\($0.peerID)-\($0.isConnected)" }.joined())
|
.id(viewModel.allPeers.map { "\($0.peerID)-\($0.isConnected)" }.joined())
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.background(backgroundColor)
|
|
||||||
.gesture(
|
|
||||||
DragGesture(minimumDistance: 10)
|
|
||||||
.onChanged { value in
|
|
||||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
|
||||||
let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height
|
|
||||||
|
|
||||||
// Only handle horizontal drags on the sidebar
|
|
||||||
guard abs(translationWidth) > abs(translationHeight) * 1.5 else { return }
|
|
||||||
|
|
||||||
if translationWidth > 0 {
|
|
||||||
let newOffset = min(translationWidth, 300)
|
|
||||||
sidebarDragOffset = newOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEnded { value in
|
|
||||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
|
||||||
let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height
|
|
||||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
|
||||||
|
|
||||||
// Only handle if drag is predominantly horizontal
|
|
||||||
guard abs(translationWidth) > abs(translationHeight) * 1.5 else {
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
|
||||||
if translationWidth > 100 || (translationWidth > 50 && velocity > 500) {
|
|
||||||
showSidebar = false
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
} else {
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - View Components
|
// MARK: - View Components
|
||||||
|
|
||||||
private var privateChatView: some View {
|
private var privateChatSheetView: some View {
|
||||||
HStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Vertical separator bar
|
if let privatePeerID = viewModel.selectedPrivateChatPeer {
|
||||||
Rectangle()
|
let headerContext = makePrivateHeaderContext(for: privatePeerID)
|
||||||
.fill(Color.gray.opacity(0.3))
|
|
||||||
.frame(width: 1)
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
HStack(spacing: 12) {
|
||||||
privateHeaderView
|
Button(action: {
|
||||||
Divider()
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
|
viewModel.endPrivateChat()
|
||||||
Divider()
|
}
|
||||||
inputView
|
}) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.bitchatSystem(size: 12))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(
|
||||||
|
String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat")
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
privateHeaderInfo(context: headerContext, privatePeerID: privatePeerID)
|
||||||
|
|
||||||
|
if !privatePeerID.hasPrefix("nostr_") {
|
||||||
|
Button(action: {
|
||||||
|
viewModel.toggleFavorite(peerID: headerContext.headerPeerID)
|
||||||
|
}) {
|
||||||
|
Image(systemName: viewModel.isFavorite(peerID: headerContext.headerPeerID) ? "star.fill" : "star")
|
||||||
|
.font(.bitchatSystem(size: 14))
|
||||||
|
.foregroundColor(viewModel.isFavorite(peerID: headerContext.headerPeerID) ? Color.yellow : textColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(
|
||||||
|
viewModel.isFavorite(peerID: headerContext.headerPeerID)
|
||||||
|
? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
|
||||||
|
: String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
|
viewModel.endPrivateChat()
|
||||||
|
showSidebar = true
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Close")
|
||||||
|
}
|
||||||
|
.frame(height: headerHeight)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.background(backgroundColor)
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
|
||||||
.foregroundColor(textColor)
|
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
Divider()
|
||||||
|
inputView
|
||||||
}
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: String) -> some View {
|
||||||
|
Button(action: {
|
||||||
|
viewModel.showFingerprint(for: context.headerPeerID)
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if let connectionState = context.peer?.connectionState {
|
||||||
|
switch connectionState {
|
||||||
|
case .bluetoothConnected:
|
||||||
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
|
.font(.bitchatSystem(size: 14))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
|
||||||
|
case .meshReachable:
|
||||||
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
|
.font(.bitchatSystem(size: 14))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
|
||||||
|
case .nostrAvailable:
|
||||||
|
Image(systemName: "globe")
|
||||||
|
.font(.bitchatSystem(size: 14))
|
||||||
|
.foregroundColor(.purple)
|
||||||
|
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||||
|
case .offline:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
} else if viewModel.meshService.isPeerReachable(PeerID(str: context.headerPeerID)) {
|
||||||
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
|
.font(.bitchatSystem(size: 14))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
|
||||||
|
} else if context.isNostrAvailable {
|
||||||
|
Image(systemName: "globe")
|
||||||
|
.font(.bitchatSystem(size: 14))
|
||||||
|
.foregroundColor(.purple)
|
||||||
|
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||||
|
} else if viewModel.meshService.isPeerConnected(PeerID(str: context.headerPeerID)) || viewModel.connectedPeers.contains(context.headerPeerID) {
|
||||||
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
|
.font(.bitchatSystem(size: 14))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(context.displayName)
|
||||||
|
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
if !privatePeerID.hasPrefix("nostr_") {
|
||||||
|
let statusPeerID: String = {
|
||||||
|
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||||
|
return short
|
||||||
|
}
|
||||||
|
return context.headerPeerID
|
||||||
|
}()
|
||||||
|
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
|
if let icon = encryptionStatus.icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.bitchatSystem(size: 14))
|
||||||
|
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
|
||||||
|
encryptionStatus == .noiseSecured ? textColor :
|
||||||
|
Color.red)
|
||||||
|
.accessibilityLabel(
|
||||||
|
String(
|
||||||
|
format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"),
|
||||||
|
locale: .current,
|
||||||
|
encryptionStatus.accessibilityDescription
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(
|
||||||
|
String(
|
||||||
|
format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"),
|
||||||
|
locale: .current,
|
||||||
|
context.displayName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.accessibilityHint(
|
||||||
|
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
|
||||||
|
)
|
||||||
|
.frame(height: headerHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePrivateHeaderContext(for privatePeerID: String) -> PrivateHeaderContext {
|
||||||
|
let headerPeerID: String = {
|
||||||
|
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||||
|
return short
|
||||||
|
}
|
||||||
|
return privatePeerID
|
||||||
|
}()
|
||||||
|
|
||||||
|
let peer = viewModel.getPeer(byID: headerPeerID)
|
||||||
|
|
||||||
|
let displayName: String = {
|
||||||
|
if privatePeerID.hasPrefix("nostr_"), case .location(let ch) = locationManager.selectedChannel {
|
||||||
|
let disp = viewModel.geohashDisplayName(for: privatePeerID)
|
||||||
|
return "#\(ch.geohash)/@\(disp)"
|
||||||
|
}
|
||||||
|
if let name = peer?.displayName { return name }
|
||||||
|
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: headerPeerID)) { return name }
|
||||||
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
|
||||||
|
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
|
if headerPeerID.count == 16 {
|
||||||
|
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: headerPeerID))
|
||||||
|
if let id = candidates.first,
|
||||||
|
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
|
||||||
|
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||||
|
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||||
|
}
|
||||||
|
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
|
||||||
|
let fp = keyData.sha256Fingerprint()
|
||||||
|
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
||||||
|
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||||
|
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||||
|
}()
|
||||||
|
|
||||||
|
let isNostrAvailable: Bool = {
|
||||||
|
guard let connectionState = peer?.connectionState else {
|
||||||
|
if let noiseKey = Data(hexString: headerPeerID),
|
||||||
|
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
|
favoriteStatus.isMutual {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return connectionState == .nostrAvailable
|
||||||
|
}()
|
||||||
|
|
||||||
|
return PrivateHeaderContext(
|
||||||
|
headerPeerID: headerPeerID,
|
||||||
|
peer: peer,
|
||||||
|
displayName: displayName,
|
||||||
|
isNostrAvailable: isNostrAvailable
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute channel-aware people count and color for toolbar (cross-platform)
|
// Compute channel-aware people count and color for toolbar (cross-platform)
|
||||||
@@ -1210,7 +1355,6 @@ struct ContentView: View {
|
|||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
showSidebar.toggle()
|
showSidebar.toggle()
|
||||||
sidebarDragOffset = 0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showVerifySheet) {
|
.sheet(isPresented: $showVerifySheet) {
|
||||||
@@ -1319,228 +1463,6 @@ struct ContentView: View {
|
|||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor.opacity(0.95))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var privateHeaderView: some View {
|
|
||||||
Group {
|
|
||||||
if let privatePeerID = viewModel.selectedPrivateChatPeer {
|
|
||||||
privateHeaderContent(for: privatePeerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func privateHeaderContent(for privatePeerID: String) -> some View {
|
|
||||||
// Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only.
|
|
||||||
let headerPeerID: String = {
|
|
||||||
if privatePeerID.count == 64 {
|
|
||||||
// Map stable Noise key to short ID if we know it (even if not directly connected)
|
|
||||||
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short }
|
|
||||||
}
|
|
||||||
return privatePeerID
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Resolve peer object for header context (may be offline favorite)
|
|
||||||
let peer = viewModel.getPeer(byID: headerPeerID)
|
|
||||||
let privatePeerNick: String = {
|
|
||||||
if privatePeerID.hasPrefix("nostr_") {
|
|
||||||
// Build geohash DM header: "#<ghash>/@name#abcd"
|
|
||||||
if case .location(let ch) = locationManager.selectedChannel {
|
|
||||||
let disp = viewModel.geohashDisplayName(for: privatePeerID)
|
|
||||||
return "#\(ch.geohash)/@\(disp)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Try mesh/unified peer display
|
|
||||||
if let name = peer?.displayName { return name }
|
|
||||||
// Try direct mesh nickname (connected-only)
|
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: headerPeerID)) { return name }
|
|
||||||
// Try favorite nickname by stable Noise key
|
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
|
|
||||||
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
|
||||||
// Fallback: resolve from persisted social identity via fingerprint mapping
|
|
||||||
if headerPeerID.count == 16 {
|
|
||||||
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: headerPeerID))
|
|
||||||
if let id = candidates.first,
|
|
||||||
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
|
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
|
||||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
|
||||||
}
|
|
||||||
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
|
|
||||||
let fp = keyData.sha256Fingerprint()
|
|
||||||
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
|
||||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
|
||||||
}()
|
|
||||||
let isNostrAvailable: Bool = {
|
|
||||||
guard let connectionState = peer?.connectionState else {
|
|
||||||
// Check if we can reach this peer via Nostr even if not in allPeers
|
|
||||||
if let noiseKey = Data(hexString: headerPeerID),
|
|
||||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
|
||||||
favoriteStatus.isMutual {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
switch connectionState {
|
|
||||||
case .nostrAvailable:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
ZStack {
|
|
||||||
// Center content - always perfectly centered
|
|
||||||
Button(action: {
|
|
||||||
viewModel.showFingerprint(for: headerPeerID)
|
|
||||||
}) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
// Show transport icon based on connection state (like peer list)
|
|
||||||
if let connectionState = peer?.connectionState {
|
|
||||||
switch connectionState {
|
|
||||||
case .bluetoothConnected:
|
|
||||||
// Radio icon for mesh connection
|
|
||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
|
||||||
.font(.bitchatSystem(size: 14))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.accessibilityLabel(
|
|
||||||
String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
|
|
||||||
)
|
|
||||||
case .meshReachable:
|
|
||||||
// point.3 filled icon for reachable via mesh (not directly connected)
|
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
|
||||||
.font(.bitchatSystem(size: 14))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.accessibilityLabel(
|
|
||||||
String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
|
|
||||||
)
|
|
||||||
case .nostrAvailable:
|
|
||||||
// Purple globe for Nostr
|
|
||||||
Image(systemName: "globe")
|
|
||||||
.font(.bitchatSystem(size: 14))
|
|
||||||
.foregroundColor(.purple)
|
|
||||||
.accessibilityLabel(
|
|
||||||
String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")
|
|
||||||
)
|
|
||||||
case .offline:
|
|
||||||
// Should not happen for PM header, but handle gracefully
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
} else if viewModel.meshService.isPeerReachable(PeerID(str: headerPeerID)) {
|
|
||||||
// Fallback: reachable via mesh but not in current peer list
|
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
|
||||||
.font(.bitchatSystem(size: 14))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.accessibilityLabel(
|
|
||||||
String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
|
|
||||||
)
|
|
||||||
} else if isNostrAvailable {
|
|
||||||
// Fallback to Nostr if peer not in list but is mutual favorite
|
|
||||||
Image(systemName: "globe")
|
|
||||||
.font(.bitchatSystem(size: 14))
|
|
||||||
.foregroundColor(.purple)
|
|
||||||
.accessibilityLabel(
|
|
||||||
String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")
|
|
||||||
)
|
|
||||||
} else if viewModel.meshService.isPeerConnected(PeerID(str: headerPeerID)) || viewModel.connectedPeers.contains(headerPeerID) {
|
|
||||||
// Fallback: if peer lookup is missing but mesh reports connected, show radio
|
|
||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
|
||||||
.font(.bitchatSystem(size: 14))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.accessibilityLabel(
|
|
||||||
String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text("\(privatePeerNick)")
|
|
||||||
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
|
|
||||||
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
|
|
||||||
if !privatePeerID.hasPrefix("nostr_") {
|
|
||||||
// Use short peer ID if available for encryption status (sessions keyed by short ID)
|
|
||||||
let statusPeerID: String = {
|
|
||||||
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
|
||||||
return short
|
|
||||||
}
|
|
||||||
return headerPeerID
|
|
||||||
}()
|
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
|
||||||
if let icon = encryptionStatus.icon {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.font(.bitchatSystem(size: 14))
|
|
||||||
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
|
|
||||||
encryptionStatus == .noiseSecured ? textColor :
|
|
||||||
Color.red)
|
|
||||||
.accessibilityLabel(
|
|
||||||
String(
|
|
||||||
format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"),
|
|
||||||
locale: .current,
|
|
||||||
encryptionStatus.accessibilityDescription
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.accessibilityLabel(
|
|
||||||
String(
|
|
||||||
format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"),
|
|
||||||
locale: .current,
|
|
||||||
privatePeerNick
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.accessibilityHint(
|
|
||||||
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
// Left and right buttons positioned with HStack
|
|
||||||
HStack {
|
|
||||||
Button(action: {
|
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
|
||||||
showPrivateChat = false
|
|
||||||
viewModel.endPrivateChat()
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Image(systemName: "chevron.left")
|
|
||||||
.font(.bitchatSystem(size: 12))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.frame(width: 44, height: 44, alignment: .leading)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel(
|
|
||||||
String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat")
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Favorite button (hidden for geohash DMs)
|
|
||||||
if !(privatePeerID.hasPrefix("nostr_")) {
|
|
||||||
Button(action: {
|
|
||||||
viewModel.toggleFavorite(peerID: headerPeerID)
|
|
||||||
}) {
|
|
||||||
Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star")
|
|
||||||
.font(.bitchatSystem(size: 16))
|
|
||||||
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel(
|
|
||||||
viewModel.isFavorite(peerID: privatePeerID)
|
|
||||||
? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
|
|
||||||
: String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
|
|
||||||
)
|
|
||||||
.accessibilityHint(
|
|
||||||
String(localized: "content.accessibility.toggle_favorite_hint", comment: "Accessibility hint for toggling favorite status")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: headerHeight)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.background(backgroundColor.opacity(0.95))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notes Counter Subscription Helper
|
// MARK: - Notes Counter Subscription Helper
|
||||||
|
|||||||
Reference in New Issue
Block a user