mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:05:20 +00:00
Merge pull request #253 from permissionlesstech/ui/move-verified-badge-position
Move encryption status icons and implement slide-over navigation
This commit is contained in:
@@ -129,7 +129,7 @@ class ChatViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show welcome message after delay if still no peers
|
// Show welcome message after delay if still no peers
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if self.connectedPeers.isEmpty && self.messages.isEmpty {
|
if self.connectedPeers.isEmpty && self.messages.isEmpty {
|
||||||
let welcomeMessage = BitchatMessage(
|
let welcomeMessage = BitchatMessage(
|
||||||
@@ -2107,7 +2107,6 @@ class ChatViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// System message
|
// System message
|
||||||
let content = AttributedString("* \(message.content) *")
|
|
||||||
var contentStyle = AttributeContainer()
|
var contentStyle = AttributeContainer()
|
||||||
// Check for welcome message
|
// Check for welcome message
|
||||||
if message.content.contains("get people around you to download bitchat") {
|
if message.content.contains("get people around you to download bitchat") {
|
||||||
@@ -2115,6 +2114,7 @@ class ChatViewModel: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
contentStyle.foregroundColor = Color.gray
|
contentStyle.foregroundColor = Color.gray
|
||||||
}
|
}
|
||||||
|
let content = AttributedString("* \(message.content) *")
|
||||||
contentStyle.font = .system(size: 12, design: .monospaced).italic()
|
contentStyle.font = .system(size: 12, design: .monospaced).italic()
|
||||||
result.append(content.mergingAttributes(contentStyle))
|
result.append(content.mergingAttributes(contentStyle))
|
||||||
}
|
}
|
||||||
|
|||||||
+513
-370
@@ -27,6 +27,9 @@ struct ContentView: View {
|
|||||||
@State private var showCommandSuggestions = false
|
@State private var showCommandSuggestions = false
|
||||||
@State private var commandSuggestions: [String] = []
|
@State private var commandSuggestions: [String] = []
|
||||||
@State private var showLeaveChannelAlert = false
|
@State private var showLeaveChannelAlert = false
|
||||||
|
@State private var backSwipeOffset: CGFloat = 0
|
||||||
|
@State private var showPrivateChat = false
|
||||||
|
@State private var showChannel = false
|
||||||
|
|
||||||
private var backgroundColor: Color {
|
private var backgroundColor: Color {
|
||||||
colorScheme == .dark ? Color.black : Color.white
|
colorScheme == .dark ? Color.black : Color.white
|
||||||
@@ -41,82 +44,119 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
GeometryReader { geometry in
|
||||||
// Main content
|
ZStack {
|
||||||
GeometryReader { geometry in
|
// Base layer - Main public chat (always visible)
|
||||||
ZStack {
|
mainChatView
|
||||||
VStack(spacing: 0) {
|
|
||||||
headerView
|
// Private chat slide-over
|
||||||
Divider()
|
if viewModel.selectedPrivateChatPeer != nil {
|
||||||
messagesView
|
privateChatView
|
||||||
Divider()
|
.frame(width: geometry.size.width)
|
||||||
inputView
|
.background(backgroundColor)
|
||||||
}
|
.transition(.asymmetric(
|
||||||
.background(backgroundColor)
|
insertion: .move(edge: .trailing),
|
||||||
.foregroundColor(textColor)
|
removal: .move(edge: .trailing)
|
||||||
.gesture(
|
))
|
||||||
DragGesture()
|
.offset(x: showPrivateChat ? 0 : geometry.size.width)
|
||||||
.onChanged { value in
|
.offset(x: backSwipeOffset)
|
||||||
// Only respond to leftward swipes when sidebar is closed
|
.gesture(
|
||||||
// or rightward swipes when sidebar is open
|
DragGesture()
|
||||||
if !showSidebar && value.translation.width < 0 {
|
.onChanged { value in
|
||||||
sidebarDragOffset = max(value.translation.width, -geometry.size.width * 0.7)
|
if value.translation.width > 0 {
|
||||||
} else if showSidebar && value.translation.width > 0 {
|
backSwipeOffset = min(value.translation.width, geometry.size.width)
|
||||||
sidebarDragOffset = min(-geometry.size.width * 0.7 + value.translation.width, 0)
|
}
|
||||||
}
|
}
|
||||||
}
|
.onEnded { value in
|
||||||
.onEnded { value in
|
if value.translation.width > 50 || (value.translation.width > 30 && value.velocity.width > 300) {
|
||||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
if !showSidebar {
|
showPrivateChat = false
|
||||||
// Opening gesture (swipe left)
|
backSwipeOffset = 0
|
||||||
if value.translation.width < -100 || (value.translation.width < -50 && value.velocity.width < -500) {
|
viewModel.endPrivateChat()
|
||||||
showSidebar = true
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
} else {
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Closing gesture (swipe right)
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
if value.translation.width > 100 || (value.translation.width > 50 && value.velocity.width > 500) {
|
backSwipeOffset = 0
|
||||||
showSidebar = false
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
} else {
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: showPrivateChat)
|
||||||
|
|
||||||
// Sidebar overlay
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
// Tap to dismiss area
|
|
||||||
Color.clear
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
|
||||||
showSidebar = false
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sidebarView
|
|
||||||
#if os(macOS)
|
|
||||||
.frame(width: min(300, geometry.size.width * 0.4))
|
|
||||||
#else
|
|
||||||
.frame(width: geometry.size.width * 0.7)
|
|
||||||
#endif
|
|
||||||
.transition(.move(edge: .trailing))
|
|
||||||
}
|
|
||||||
.offset(x: showSidebar ? -sidebarDragOffset : geometry.size.width - sidebarDragOffset)
|
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: showSidebar)
|
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: sidebarDragOffset)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Channel slide-over
|
||||||
|
if viewModel.currentChannel != nil {
|
||||||
|
channelView
|
||||||
|
.frame(width: geometry.size.width)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .move(edge: .trailing),
|
||||||
|
removal: .move(edge: .trailing)
|
||||||
|
))
|
||||||
|
.offset(x: showChannel ? 0 : geometry.size.width)
|
||||||
|
.offset(x: backSwipeOffset)
|
||||||
|
.gesture(
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
if value.translation.width > 0 {
|
||||||
|
backSwipeOffset = min(value.translation.width, geometry.size.width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
if value.translation.width > 50 || (value.translation.width > 30 && value.velocity.width > 300) {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
showChannel = false
|
||||||
|
backSwipeOffset = 0
|
||||||
|
viewModel.switchToChannel(nil)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
backSwipeOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: showChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidebar overlay
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
// Tap to dismiss area
|
||||||
|
Color.clear
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
showSidebar = false
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebarView
|
||||||
|
#if os(macOS)
|
||||||
|
.frame(width: min(300, geometry.size.width * 0.4))
|
||||||
|
#else
|
||||||
|
.frame(width: geometry.size.width * 0.7)
|
||||||
|
#endif
|
||||||
|
.transition(.move(edge: .trailing))
|
||||||
|
}
|
||||||
|
.offset(x: showSidebar ? -sidebarDragOffset : geometry.size.width - sidebarDragOffset)
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: showSidebar)
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: sidebarDragOffset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 600, minHeight: 400)
|
.frame(minWidth: 600, minHeight: 400)
|
||||||
#endif
|
#endif
|
||||||
|
.onChange(of: viewModel.selectedPrivateChatPeer) { newValue in
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
showPrivateChat = newValue != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.currentChannel) { newValue in
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
showChannel = newValue != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(isPresented: $showAppInfo) {
|
.sheet(isPresented: $showAppInfo) {
|
||||||
AppInfoView()
|
AppInfoView()
|
||||||
}
|
}
|
||||||
@@ -175,295 +215,16 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var headerView: some View {
|
private func messagesView(for channel: String?, privatePeer: String?) -> some View {
|
||||||
HStack {
|
|
||||||
if let privatePeerID = viewModel.selectedPrivateChatPeer,
|
|
||||||
let privatePeerNick = viewModel.meshService.getPeerNicknames()[privatePeerID] {
|
|
||||||
// Private chat header
|
|
||||||
Button(action: {
|
|
||||||
viewModel.endPrivateChat()
|
|
||||||
}) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "chevron.left")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
Text("back")
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
}
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Back to main chat")
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
viewModel.showFingerprint(for: privatePeerID)
|
|
||||||
}) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
// Dynamic encryption status icon
|
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: privatePeerID)
|
|
||||||
Image(systemName: encryptionStatus.icon)
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green :
|
|
||||||
encryptionStatus == .noiseSecured ? Color.orange :
|
|
||||||
Color.red)
|
|
||||||
.accessibilityLabel("Encryption status: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : "not encrypted")")
|
|
||||||
Text("\(privatePeerNick)")
|
|
||||||
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
|
||||||
.foregroundColor(Color.orange)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.accessibilityLabel("Private chat with \(privatePeerNick)")
|
|
||||||
.accessibilityHint("Tap to view encryption fingerprint")
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Favorite button
|
|
||||||
Button(action: {
|
|
||||||
viewModel.toggleFavorite(peerID: privatePeerID)
|
|
||||||
}) {
|
|
||||||
Image(systemName: viewModel.isFavorite(peerID: privatePeerID) ? "star.fill" : "star")
|
|
||||||
.font(.system(size: 16))
|
|
||||||
.foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites")
|
|
||||||
.accessibilityHint("Double tap to toggle favorite status")
|
|
||||||
} else if let currentChannel = viewModel.currentChannel {
|
|
||||||
// Channel header
|
|
||||||
Button(action: {
|
|
||||||
viewModel.switchToChannel(nil)
|
|
||||||
}) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "chevron.left")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
Text("back")
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
}
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Back to main chat")
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
|
||||||
showSidebar.toggle()
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
if viewModel.passwordProtectedChannels.contains(currentChannel) {
|
|
||||||
Image(systemName: "lock.fill")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundColor(Color.orange)
|
|
||||||
.accessibilityLabel("Password protected channel")
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(currentChannel)
|
|
||||||
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
|
||||||
.foregroundColor(viewModel.passwordProtectedChannels.contains(currentChannel) ? Color.orange : Color.blue)
|
|
||||||
|
|
||||||
// Verification status indicator after channel name
|
|
||||||
if viewModel.passwordProtectedChannels.contains(currentChannel),
|
|
||||||
let status = viewModel.channelVerificationStatus[currentChannel] {
|
|
||||||
switch status {
|
|
||||||
case .verifying:
|
|
||||||
ProgressView()
|
|
||||||
.scaleEffect(0.5)
|
|
||||||
.frame(width: 12, height: 12)
|
|
||||||
case .verified:
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(Color.green)
|
|
||||||
case .failed:
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(Color.red)
|
|
||||||
case .unverified:
|
|
||||||
Image(systemName: "questionmark.circle")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(Color.gray)
|
|
||||||
.help("Password verification pending")
|
|
||||||
}
|
|
||||||
} else if viewModel.passwordProtectedChannels.contains(currentChannel) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
// Show retention indicator for all users
|
|
||||||
if viewModel.retentionEnabledChannels.contains(currentChannel) {
|
|
||||||
Image(systemName: "bookmark.fill")
|
|
||||||
.font(.system(size: 16))
|
|
||||||
.foregroundColor(Color.yellow)
|
|
||||||
.help("Messages in this channel are being saved locally")
|
|
||||||
.accessibilityLabel("Message retention enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save button - only for channel owner
|
|
||||||
if viewModel.isChannelOwner(currentChannel) {
|
|
||||||
Button(action: {
|
|
||||||
viewModel.sendMessage("/save")
|
|
||||||
}) {
|
|
||||||
Image(systemName: viewModel.retentionEnabledChannels.contains(currentChannel) ? "bookmark.slash" : "bookmark")
|
|
||||||
.font(.system(size: 16))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.help(viewModel.retentionEnabledChannels.contains(currentChannel) ? "Disable message retention" : "Enable message retention")
|
|
||||||
.accessibilityLabel(viewModel.retentionEnabledChannels.contains(currentChannel) ? "Disable message retention" : "Enable message retention")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Password button for channel creator only
|
|
||||||
if viewModel.isChannelOwner(currentChannel) {
|
|
||||||
Button(action: {
|
|
||||||
// Toggle password protection
|
|
||||||
if viewModel.passwordProtectedChannels.contains(currentChannel) {
|
|
||||||
viewModel.removeChannelPassword(for: currentChannel)
|
|
||||||
} else {
|
|
||||||
// Show password input
|
|
||||||
showPasswordInput = true
|
|
||||||
passwordInputChannel = currentChannel
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Image(systemName: viewModel.passwordProtectedChannels.contains(currentChannel) ? "lock.fill" : "lock")
|
|
||||||
.font(.system(size: 16))
|
|
||||||
.foregroundColor(viewModel.passwordProtectedChannels.contains(currentChannel) ? Color.yellow : textColor)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel(viewModel.passwordProtectedChannels.contains(currentChannel) ? "Remove channel password" : "Set channel password")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leave channel button
|
|
||||||
Button(action: {
|
|
||||||
showLeaveChannelAlert = true
|
|
||||||
}) {
|
|
||||||
Image(systemName: "xmark.circle")
|
|
||||||
.font(.system(size: 16))
|
|
||||||
.foregroundColor(Color.red.opacity(0.8))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.alert("leave channel?", isPresented: $showLeaveChannelAlert) {
|
|
||||||
Button("cancel", role: .cancel) { }
|
|
||||||
Button("leave", role: .destructive) {
|
|
||||||
viewModel.leaveChannel(currentChannel)
|
|
||||||
}
|
|
||||||
} message: {
|
|
||||||
Text("sure you want to leave \(currentChannel)?")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Public chat header
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text("bitchat*")
|
|
||||||
.font(.system(size: 18, weight: .medium, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.onTapGesture(count: 3) {
|
|
||||||
// PANIC: Triple-tap to clear all data
|
|
||||||
viewModel.panicClearAllData()
|
|
||||||
}
|
|
||||||
.onTapGesture(count: 1) {
|
|
||||||
// Single tap for app info
|
|
||||||
showAppInfo = true
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
Text("@")
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
.foregroundColor(secondaryTextColor)
|
|
||||||
|
|
||||||
TextField("nickname", text: $viewModel.nickname)
|
|
||||||
.textFieldStyle(.plain)
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
.frame(maxWidth: 100)
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.onChange(of: viewModel.nickname) { _ in
|
|
||||||
viewModel.saveNickname()
|
|
||||||
}
|
|
||||||
.onSubmit {
|
|
||||||
viewModel.saveNickname()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// People counter with unread indicator
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
// Check for any unread channel messages
|
|
||||||
let hasUnreadChannelMessages = viewModel.unreadChannelMessages.values.contains { $0 > 0 }
|
|
||||||
|
|
||||||
if hasUnreadChannelMessages {
|
|
||||||
Image(systemName: "number")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(Color.blue)
|
|
||||||
.accessibilityLabel("Unread channel messages")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !viewModel.unreadPrivateMessages.isEmpty {
|
|
||||||
Image(systemName: "envelope.fill")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(Color.orange)
|
|
||||||
.accessibilityLabel("Unread private messages")
|
|
||||||
}
|
|
||||||
|
|
||||||
let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count
|
|
||||||
let channelCount = viewModel.joinedChannels.count
|
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
// People icon with count
|
|
||||||
Image(systemName: "person.2.fill")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.accessibilityLabel("\(otherPeersCount) connected \(otherPeersCount == 1 ? "person" : "people")")
|
|
||||||
Text("\(otherPeersCount)")
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
|
|
||||||
// Channels icon with count (only if there are channels)
|
|
||||||
if channelCount > 0 {
|
|
||||||
Text("·")
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
Image(systemName: "square.split.2x2")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.accessibilityLabel("\(channelCount) active \(channelCount == 1 ? "channel" : "channels")")
|
|
||||||
Text("\(channelCount)")
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundColor(viewModel.isConnected ? textColor : Color.red)
|
|
||||||
}
|
|
||||||
.onTapGesture {
|
|
||||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
|
||||||
showSidebar.toggle()
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: 44) // Fixed height to prevent bouncing
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.background(backgroundColor.opacity(0.95))
|
|
||||||
}
|
|
||||||
|
|
||||||
private var messagesView: some View {
|
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 2) {
|
LazyVStack(alignment: .leading, spacing: 2) {
|
||||||
let messages: [BitchatMessage] = {
|
let messages: [BitchatMessage] = {
|
||||||
if let privatePeer = viewModel.selectedPrivateChatPeer {
|
if let privatePeer = privatePeer {
|
||||||
let msgs = viewModel.getPrivateChatMessages(for: privatePeer)
|
let msgs = viewModel.getPrivateChatMessages(for: privatePeer)
|
||||||
return msgs
|
return msgs
|
||||||
} else if let currentChannel = viewModel.currentChannel {
|
} else if let channel = channel {
|
||||||
let msgs = viewModel.getChannelMessages(currentChannel)
|
let msgs = viewModel.getChannelMessages(channel)
|
||||||
return msgs
|
return msgs
|
||||||
} else {
|
} else {
|
||||||
return viewModel.messages
|
return viewModel.messages
|
||||||
@@ -527,14 +288,14 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.onChange(of: viewModel.messages.count) { _ in
|
.onChange(of: viewModel.messages.count) { _ in
|
||||||
if viewModel.selectedPrivateChatPeer == nil && !viewModel.messages.isEmpty {
|
if channel == nil && privatePeer == nil && !viewModel.messages.isEmpty {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
proxy.scrollTo(viewModel.messages.last?.id, anchor: .bottom)
|
proxy.scrollTo(viewModel.messages.last?.id, anchor: .bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.privateChats) { _ in
|
.onChange(of: viewModel.privateChats) { _ in
|
||||||
if let peerID = viewModel.selectedPrivateChatPeer,
|
if let peerID = privatePeer,
|
||||||
let messages = viewModel.privateChats[peerID],
|
let messages = viewModel.privateChats[peerID],
|
||||||
!messages.isEmpty {
|
!messages.isEmpty {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
@@ -542,18 +303,18 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.selectedPrivateChatPeer) { newPeerID in
|
.onChange(of: viewModel.channelMessages) { _ in
|
||||||
// When switching to a private chat, send read receipts
|
if let channelName = channel,
|
||||||
if let peerID = newPeerID {
|
let messages = viewModel.channelMessages[channelName],
|
||||||
// Small delay to ensure messages are loaded
|
!messages.isEmpty {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
withAnimation {
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
proxy.scrollTo(messages.last?.id, anchor: .bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Also check when view appears
|
// Also check when view appears
|
||||||
if let peerID = viewModel.selectedPrivateChatPeer {
|
if let peerID = privatePeer {
|
||||||
// Try multiple times to ensure read receipts are sent
|
// Try multiple times to ensure read receipts are sent
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
|
|
||||||
@@ -973,14 +734,14 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.connectedPeers.isEmpty {
|
if viewModel.connectedPeers.isEmpty {
|
||||||
Text("No one connected")
|
Text("no one connected...")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
} else if let currentChannel = viewModel.currentChannel,
|
} else if let currentChannel = viewModel.currentChannel,
|
||||||
let channelMemberIDs = viewModel.channelMembers[currentChannel],
|
let channelMemberIDs = viewModel.channelMembers[currentChannel],
|
||||||
channelMemberIDs.isEmpty {
|
channelMemberIDs.isEmpty {
|
||||||
Text("No one in this channel yet")
|
Text("no one in this channel yet...")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
@@ -1049,18 +810,6 @@ struct ContentView: View {
|
|||||||
.accessibilityLabel("Signal strength: \(rssi > -60 ? "excellent" : rssi > -70 ? "good" : rssi > -80 ? "fair" : "poor")")
|
.accessibilityLabel("Signal strength: \(rssi > -60 ? "excellent" : rssi > -70 ? "good" : rssi > -80 ? "fair" : "poor")")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encryption status icon (between connection dot and name)
|
|
||||||
if !isMe {
|
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: peerID)
|
|
||||||
Image(systemName: encryptionStatus.icon)
|
|
||||||
.font(.system(size: 10))
|
|
||||||
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green :
|
|
||||||
encryptionStatus == .noiseSecured ? textColor :
|
|
||||||
encryptionStatus == .noiseHandshaking ? Color.orange :
|
|
||||||
Color.red)
|
|
||||||
.accessibilityLabel("Encryption: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : encryptionStatus == .noiseHandshaking ? "establishing" : "none")")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Peer name
|
// Peer name
|
||||||
if isMe {
|
if isMe {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -1091,6 +840,16 @@ struct ContentView: View {
|
|||||||
viewModel.showFingerprint(for: peerID)
|
viewModel.showFingerprint(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Encryption status icon (after peer name)
|
||||||
|
let encryptionStatus = viewModel.getEncryptionStatus(for: peerID)
|
||||||
|
Image(systemName: encryptionStatus.icon)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green :
|
||||||
|
encryptionStatus == .noiseSecured ? textColor :
|
||||||
|
encryptionStatus == .noiseHandshaking ? Color.orange :
|
||||||
|
Color.red)
|
||||||
|
.accessibilityLabel("Encryption: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : encryptionStatus == .noiseHandshaking ? "establishing" : "none")")
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Favorite star
|
// Favorite star
|
||||||
@@ -1119,6 +878,390 @@ struct ContentView: View {
|
|||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - View Components
|
||||||
|
|
||||||
|
private var mainChatView: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
mainHeaderView
|
||||||
|
Divider()
|
||||||
|
messagesView(for: nil, privatePeer: nil)
|
||||||
|
Divider()
|
||||||
|
inputView
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.gesture(
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
if !showSidebar && value.translation.width < 0 {
|
||||||
|
sidebarDragOffset = max(value.translation.width, -300)
|
||||||
|
} else if showSidebar && value.translation.width > 0 {
|
||||||
|
sidebarDragOffset = min(-300 + value.translation.width, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
if !showSidebar {
|
||||||
|
if value.translation.width < -100 || (value.translation.width < -50 && value.velocity.width < -500) {
|
||||||
|
showSidebar = true
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
} else {
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if value.translation.width > 100 || (value.translation.width > 50 && value.velocity.width > 500) {
|
||||||
|
showSidebar = false
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
} else {
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var privateChatView: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
// Vertical separator bar
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
.frame(width: 1)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
privateHeaderView
|
||||||
|
Divider()
|
||||||
|
messagesView(for: nil, privatePeer: viewModel.selectedPrivateChatPeer)
|
||||||
|
Divider()
|
||||||
|
inputView
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var channelView: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
// Vertical separator bar
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
.frame(width: 1)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
channelHeaderView
|
||||||
|
Divider()
|
||||||
|
messagesView(for: viewModel.currentChannel, privatePeer: nil)
|
||||||
|
Divider()
|
||||||
|
inputView
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mainHeaderView: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("bitchat*")
|
||||||
|
.font(.system(size: 18, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.onTapGesture(count: 3) {
|
||||||
|
// PANIC: Triple-tap to clear all data
|
||||||
|
viewModel.panicClearAllData()
|
||||||
|
}
|
||||||
|
.onTapGesture(count: 1) {
|
||||||
|
// Single tap for app info
|
||||||
|
showAppInfo = true
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text("@")
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
.foregroundColor(secondaryTextColor)
|
||||||
|
|
||||||
|
TextField("nickname", text: $viewModel.nickname)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
.frame(maxWidth: 100)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.onChange(of: viewModel.nickname) { _ in
|
||||||
|
viewModel.saveNickname()
|
||||||
|
}
|
||||||
|
.onSubmit {
|
||||||
|
viewModel.saveNickname()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// People counter with unread indicator
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
// Check for any unread channel messages
|
||||||
|
let hasUnreadChannelMessages = viewModel.unreadChannelMessages.values.contains { $0 > 0 }
|
||||||
|
|
||||||
|
if hasUnreadChannelMessages {
|
||||||
|
Image(systemName: "number")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(Color.blue)
|
||||||
|
.accessibilityLabel("Unread channel messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.unreadPrivateMessages.isEmpty {
|
||||||
|
Image(systemName: "envelope.fill")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(Color.orange)
|
||||||
|
.accessibilityLabel("Unread private messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count
|
||||||
|
let channelCount = viewModel.joinedChannels.count
|
||||||
|
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
// People icon with count
|
||||||
|
Image(systemName: "person.2.fill")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.accessibilityLabel("\(otherPeersCount) connected \(otherPeersCount == 1 ? "person" : "people")")
|
||||||
|
Text("\(otherPeersCount)")
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
// Channels icon with count (only if there are channels)
|
||||||
|
if channelCount > 0 {
|
||||||
|
Text("·")
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
Image(systemName: "square.split.2x2")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.accessibilityLabel("\(channelCount) active \(channelCount == 1 ? "channel" : "channels")")
|
||||||
|
Text("\(channelCount)")
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(viewModel.isConnected ? textColor : Color.red)
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
showSidebar.toggle()
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 44)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.background(backgroundColor.opacity(0.95))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var privateHeaderView: some View {
|
||||||
|
Group {
|
||||||
|
if let privatePeerID = viewModel.selectedPrivateChatPeer,
|
||||||
|
let privatePeerNick = viewModel.meshService.getPeerNicknames()[privatePeerID] {
|
||||||
|
HStack {
|
||||||
|
Button(action: {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
showPrivateChat = false
|
||||||
|
viewModel.endPrivateChat()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
Text("back")
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
}
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Back to main chat")
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
viewModel.showFingerprint(for: privatePeerID)
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("\(privatePeerNick)")
|
||||||
|
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(Color.orange)
|
||||||
|
// Dynamic encryption status icon
|
||||||
|
let encryptionStatus = viewModel.getEncryptionStatus(for: privatePeerID)
|
||||||
|
Image(systemName: encryptionStatus.icon)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green :
|
||||||
|
encryptionStatus == .noiseSecured ? Color.orange :
|
||||||
|
Color.red)
|
||||||
|
.accessibilityLabel("Encryption status: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : "not encrypted")")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.accessibilityLabel("Private chat with \(privatePeerNick)")
|
||||||
|
.accessibilityHint("Tap to view encryption fingerprint")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Favorite button
|
||||||
|
Button(action: {
|
||||||
|
viewModel.toggleFavorite(peerID: privatePeerID)
|
||||||
|
}) {
|
||||||
|
Image(systemName: viewModel.isFavorite(peerID: privatePeerID) ? "star.fill" : "star")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites")
|
||||||
|
.accessibilityHint("Double tap to toggle favorite status")
|
||||||
|
}
|
||||||
|
.frame(height: 44)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.background(backgroundColor.opacity(0.95))
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var channelHeaderView: some View {
|
||||||
|
Group {
|
||||||
|
if let currentChannel = viewModel.currentChannel {
|
||||||
|
HStack {
|
||||||
|
Button(action: {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
showChannel = false
|
||||||
|
viewModel.switchToChannel(nil)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
Text("back")
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
}
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Back to main chat")
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
showSidebar.toggle()
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if viewModel.passwordProtectedChannels.contains(currentChannel) {
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(Color.orange)
|
||||||
|
.accessibilityLabel("Password protected channel")
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(currentChannel)
|
||||||
|
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(viewModel.passwordProtectedChannels.contains(currentChannel) ? Color.orange : Color.blue)
|
||||||
|
|
||||||
|
// Verification status indicator after channel name
|
||||||
|
if viewModel.passwordProtectedChannels.contains(currentChannel),
|
||||||
|
let status = viewModel.channelVerificationStatus[currentChannel] {
|
||||||
|
switch status {
|
||||||
|
case .verifying:
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.5)
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
case .verified:
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(Color.green)
|
||||||
|
case .failed:
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(Color.red)
|
||||||
|
case .unverified:
|
||||||
|
Image(systemName: "questionmark.circle")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(Color.gray)
|
||||||
|
.help("Password verification pending")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// Show retention indicator for all users
|
||||||
|
if viewModel.retentionEnabledChannels.contains(currentChannel) {
|
||||||
|
Image(systemName: "bookmark.fill")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(Color.yellow)
|
||||||
|
.help("Messages in this channel are being saved locally")
|
||||||
|
.accessibilityLabel("Message retention enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save button - only for channel owner
|
||||||
|
if viewModel.isChannelOwner(currentChannel) {
|
||||||
|
Button(action: {
|
||||||
|
viewModel.sendMessage("/save")
|
||||||
|
}) {
|
||||||
|
Image(systemName: viewModel.retentionEnabledChannels.contains(currentChannel) ? "bookmark.slash" : "bookmark")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(viewModel.retentionEnabledChannels.contains(currentChannel) ? "Disable message retention" : "Enable message retention")
|
||||||
|
.accessibilityLabel(viewModel.retentionEnabledChannels.contains(currentChannel) ? "Disable message retention" : "Enable message retention")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password button for channel creator only
|
||||||
|
if viewModel.isChannelOwner(currentChannel) {
|
||||||
|
Button(action: {
|
||||||
|
// Toggle password protection
|
||||||
|
if viewModel.passwordProtectedChannels.contains(currentChannel) {
|
||||||
|
viewModel.removeChannelPassword(for: currentChannel)
|
||||||
|
} else {
|
||||||
|
// Show password input
|
||||||
|
showPasswordInput = true
|
||||||
|
passwordInputChannel = currentChannel
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: viewModel.passwordProtectedChannels.contains(currentChannel) ? "lock.fill" : "lock")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(viewModel.passwordProtectedChannels.contains(currentChannel) ? Color.yellow : textColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(viewModel.passwordProtectedChannels.contains(currentChannel) ? "Remove channel password" : "Set channel password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave channel button
|
||||||
|
Button(action: {
|
||||||
|
showLeaveChannelAlert = true
|
||||||
|
}) {
|
||||||
|
Image(systemName: "xmark.circle")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(Color.red.opacity(0.8))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.alert("leave channel?", isPresented: $showLeaveChannelAlert) {
|
||||||
|
Button("cancel", role: .cancel) { }
|
||||||
|
Button("leave", role: .destructive) {
|
||||||
|
viewModel.leaveChannel(currentChannel)
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("sure you want to leave \(currentChannel)?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 44)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.background(backgroundColor.opacity(0.95))
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper view for rendering message content with clickable hashtags
|
// Helper view for rendering message content with clickable hashtags
|
||||||
|
|||||||
Reference in New Issue
Block a user