import SwiftUI // 消息(按设备聚合,社交软件式):会话列表(每个对端设备一行)→ 点进 ConversationView 聊天线程。 // 会话由引擎 conversations() 合成——有消息历史的设备 ∪ 当前可达设备(在线 / 可唤醒,供发起新会话)。 // 可达设备可发(在线即时达、离线 iOS 走 APNs 唤醒);不可达的离线设备(离线桌面 / 浏览器)仅可查看 // 历史、撰写栏禁用。删除:单条消息(线程内长按)+ 整段会话(列表滑动 / 长按)——避免废会话堆积。 struct MessagesView: View { @Environment(EngineController.self) private var engine @State private var showClearConfirm = false var body: some View { Group { let convos = engine.conversations() if convos.isEmpty { ContentUnavailableView(t("ios.messages.empty"), systemImage: "message") } else { List { ForEach(convos) { convo in NavigationLink(value: convo.peerName) { conversationRow(convo) } // 长条行统一:滑动 + 长按都能删整段会话(清掉该设备全部消息记录)。 .swipeActions(edge: .trailing) { Button(role: .destructive) { engine.deleteConversation(convo.peerName) } label: { Label(t("ios.messages.deleteConversation"), systemImage: "trash") } } .contextMenu { Button(role: .destructive) { engine.deleteConversation(convo.peerName) } label: { Label(t("ios.messages.deleteConversation"), systemImage: "trash") } } } } .listStyle(.plain) } } .navigationTitle(t("ios.tab.messages")) .navigationDestination(for: String.self) { peer in ConversationView(peerName: peer) } .toolbar { if !engine.messages.isEmpty { ToolbarItem(placement: .topBarTrailing) { Button(role: .destructive) { showClearConfirm = true } label: { Label(t("ios.records.clear"), systemImage: "trash") } } } } .confirmationDialog(t("ios.records.clearConfirm"), isPresented: $showClearConfirm, titleVisibility: .visible) { Button(t("ios.records.clear"), role: .destructive) { engine.clearMessages() } Button(t("common.cancel"), role: .cancel) { } } } private func conversationRow(_ convo: ConversationItem) -> some View { HStack(spacing: 12) { Image(systemName: deviceSymbol(convo.deviceType)) .font(.oTitle3) .foregroundStyle(convo.online ? Color.green : Color.secondary) .frame(width: 28) VStack(alignment: .leading, spacing: 3) { Text(convo.peerName) .font(.oBody) .lineLimit(1) Text(previewText(convo)) .font(.oCaption) .foregroundStyle(.secondary) .lineLimit(1) } Spacer() VStack(alignment: .trailing, spacing: 5) { if convo.lastAt > 0 { Text(relativeTime(convo.lastAt)) .font(.oCaption2) .foregroundStyle(.secondary) } // 该设备未读红点(社交软件式逐设备计数);消息 tab 徽标=各设备未读总和(engine.unreadMessages)。 let unread = engine.unread(for: convo.peerName) if unread > 0 { Text("\(unread)") .font(.oCaption2) .fontWeight(.bold) .foregroundStyle(.white) .padding(.horizontal, 7) .padding(.vertical, 2) .background(Color.cdropAccent, in: Capsule()) } } } .padding(.vertical, 4) } // 会话副行预览:有历史显示最近一条(本端发出的加「你:」前缀);无历史(仅可达)显示发起提示。 private func previewText(_ convo: ConversationItem) -> String { if convo.lastText.isEmpty { return convo.reachable ? t("ios.messages.startHint") : t("ios.messages.viewOnly") } return convo.lastOutgoing ? "\(t("ios.messages.youPrefix"))\(convo.lastText)" : convo.lastText } } // 单个设备的聊天线程:自上而下旧→新 + 底部撰写栏。可达设备(在线 / 可唤醒)才显示撰写栏; // 不可达离线设备仅查看历史、底部显示「仅查看」提示。长按消息可删单条。 struct ConversationView: View { @Environment(EngineController.self) private var engine let peerName: String @State private var draft = "" private var thread: [MessageItem] { engine.thread(with: peerName) } private var reachable: Bool { engine.canMessage(peerName) } var body: some View { VStack(spacing: 0) { if thread.isEmpty { ContentUnavailableView(t("ios.messages.empty"), systemImage: "message") .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 10) { ForEach(thread) { msg in messageRow(msg) .id(msg.id) .contextMenu { Button(role: .destructive) { engine.deleteMessage(msg.id) } label: { Label(t("transfer.action.delete"), systemImage: "trash") } } } } .padding() } .onChange(of: thread.count) { scrollToLast(proxy) } .onAppear { scrollToLast(proxy) } } } composeBar } .navigationTitle(peerName) .navigationBarTitleDisplayMode(.inline) // 进入会话:记为当前会话(其后到达的消息不计未读)+ 清该设备已积累未读;离开置空。 .onAppear { engine.setActiveConversation(peerName) engine.markConversationRead(peerName) } .onDisappear { engine.setActiveConversation(nil) } } private func scrollToLast(_ proxy: ScrollViewProxy) { guard let last = thread.last else { return } withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } } private func messageRow(_ msg: MessageItem) -> some View { let outgoing = msg.direction == "outgoing" return HStack { if outgoing { Spacer(minLength: 48) } Text(msg.text) .padding(.horizontal, 12) .padding(.vertical, 8) .background(outgoing ? Color.cdropAccent.opacity(0.18) : Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14)) .textSelection(.enabled) if !outgoing { Spacer(minLength: 48) } } .frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading) } @ViewBuilder private var composeBar: some View { if reachable { HStack(spacing: 10) { TextField(t("ios.messages.placeholder"), text: $draft, axis: .vertical) .textFieldStyle(.roundedBorder) .lineLimit(1 ... 4) .submitLabel(.send) .onSubmit { send() } Button { send() } label: { Image(systemName: "paperplane.fill").font(.oTitle3) } .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .padding() .background(.bar) } else { Text(t("ios.messages.viewOnly")) .font(.oCaption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) .padding() .background(.bar) } } private func send() { let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } engine.sendMessage(to: peerName, text: text) draft = "" } }