349d94aa8f
- 原生数据面 libwebrtc(PLAN 决策 F):新增 Engine/LibWebRtcEngine.swift(RTCPeerConnection,端口 engine.go/session.go 线协议 + 水位背压 + ack 完成 + 不冲突落盘);NativeTransfer.swift 的 P2PEventsBridge 改 conform P2PEngineDelegate(去 gomobile);EngineController 4 个 p2p RPC 改调新引擎(completion-based + resolveOnMain)。stasel/WebRTC M149 经 SPM(exactVersion 149.0.0)。环回端到端 20MB 逐字节测过(Tests/) - UX 改造(PARITY 残余项 1/2):新增 SendComposeSheet(发送预览=文件名 + 选设备,手动多选与分享共用);分享直入预览 + scenePhase .active 兜底(修“分享后必须重启 app”);重写 MessagesView(按设备会话 + 每设备未读红点 + 删单条 / 删整设备历史);EngineController 加 conversations / unread 模型 - 品牌 logo:新增 Shared/Brand.swift(BrandMark / Wordmark / BrandLockup / BrandHero)+ Brand.xcassets/LogoMark;登录页 / 传输空态 / 设置头 / 控制中心控件接 logo;Theme 加 cdropAmber - 字体(对齐 web,仅主 app):新增 BrandFonts.swift(Orbit Gothic + Maple Mono woff2 运行期 CTFontManager 注册 + UIKit 外观代理 + .oXxx / .maple 助手);Fredoka SemiBold 字标;7 视图 .font sweep。字体不入库——.gitignore 排除 + just ios-fonts 构建前从 CDN / Google Fonts 拉取,见 Sources/Fonts/README.md - 分享引导:新增 Share/ShareGuideView(吉祥物卡 + “打开 Commilitia Drop”);ShareViewController 健壮 staging(文件 / 数据双路)+ 响应链 IMP 唤起宿主 - 全平台平均速度:传输完成态显平均速度(总字节 / 总耗时)而非末次瞬时——web store/slices/transfer.ts + TransferRow.tsx + iOS RootView 卡片 / 详情;i18n 加 transfer.avgRate - 工程:project.yml 接 SPM WebRTC + CDropTests + Fredoka UIAppFonts + Share i18n;Justfile 加 ios-sim-build + ios-fonts / ios-fonts-fredoka 配方 - 文档:ios/PLAN.md(决策 F + §10 UX + §11 品牌/字体 + §12 平均速度)、ios/PARITY.md(字体分叉条目更新 + 2026-06-28 漂移行)
241 lines
9.1 KiB
Swift
241 lines
9.1 KiB
Swift
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 = ""
|
||
}
|
||
}
|