import SwiftUI // 设备间文本消息(#13):聊天式列表 + 底部撰写栏。消息由引擎经桥推来(in-memory,关 app 即清, // 对齐 web 语义),收发都经 POST /api/message(hub SendTo 实时转发;对端离线则后端发推送)。 // 可发给任意非本机设备(在线即时达、离线走推送),故目标取全部非本机设备而非仅在线。 struct MessagesView: View { @Environment(EngineController.self) private var engine @State private var draft = "" @State private var target = "" @State private var showClearConfirm = false // 可发消息的目标:全部非本机设备(按 device_id 判本机,与设备名解耦)。 private var targets: [DeviceItem] { let selfID = engine.selfDeviceID return engine.devices.filter { d in if !selfID.isEmpty, !d.deviceID.isEmpty { return d.deviceID != selfID } return d.name != engine.deviceName } } // store 是最新在前;聊天式从上到下需旧→新。 private var ordered: [MessageItem] { engine.messages.reversed() } var body: some View { VStack(spacing: 0) { if engine.messages.isEmpty { ContentUnavailableView(t("ios.messages.empty"), systemImage: "message") .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 10) { ForEach(ordered) { 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: engine.messages.count) { scrollToLast(proxy) } .onAppear { scrollToLast(proxy) } } } composeBar } .navigationTitle(t("ios.tab.messages")) .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) { } } .onAppear { if target.isEmpty { target = targets.first?.name ?? "" } } } private func scrollToLast(_ proxy: ScrollViewProxy) { guard let last = ordered.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) } VStack(alignment: outgoing ? .trailing : .leading, spacing: 2) { Text(msg.peerName) .font(.caption2) .foregroundStyle(.secondary) 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 { VStack(spacing: 6) { if targets.isEmpty { Text(t("ios.messages.noPeers")) .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } else { HStack(spacing: 10) { Menu { ForEach(targets) { dev in Button(dev.name) { target = dev.name } } } label: { Label(target.isEmpty ? t("ios.send.pickDevice") : target, systemImage: "chevron.up.chevron.down") .font(.caption) .lineLimit(1) } 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(.title3) } .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || target.isEmpty) } } } .padding() .background(.bar) } private func send() { let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, !target.isEmpty else { return } engine.sendMessage(to: target, text: text) draft = "" } }