iOS 原生客户端:libwebrtc 数据面 + UX 改造 + 品牌/字体 + 全平台平均速度
- 原生数据面 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 漂移行)
This commit is contained in:
@@ -1,66 +1,51 @@
|
||||
import SwiftUI
|
||||
|
||||
// 设备间文本消息(#13):聊天式列表 + 底部撰写栏。消息由引擎经桥推来(in-memory,关 app 即清,
|
||||
// 对齐 web 语义),收发都经 POST /api/message(hub SendTo 实时转发;对端离线则后端发推送)。
|
||||
// 可发给任意非本机设备(在线即时达、离线走推送),故目标取全部非本机设备而非仅在线。
|
||||
// 消息(按设备聚合,社交软件式):会话列表(每个对端设备一行)→ 点进 ConversationView 聊天线程。
|
||||
// 会话由引擎 conversations() 合成——有消息历史的设备 ∪ 当前可达设备(在线 / 可唤醒,供发起新会话)。
|
||||
// 可达设备可发(在线即时达、离线 iOS 走 APNs 唤醒);不可达的离线设备(离线桌面 / 浏览器)仅可查看
|
||||
// 历史、撰写栏禁用。删除:单条消息(线程内长按)+ 整段会话(列表滑动 / 长按)——避免废会话堆积。
|
||||
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)
|
||||
Group
|
||||
{
|
||||
if engine.messages.isEmpty
|
||||
let convos = engine.conversations()
|
||||
if convos.isEmpty
|
||||
{
|
||||
ContentUnavailableView(t("ios.messages.empty"), systemImage: "message")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
else
|
||||
{
|
||||
ScrollViewReader
|
||||
{ proxy in
|
||||
ScrollView
|
||||
{
|
||||
LazyVStack(alignment: .leading, spacing: 10)
|
||||
List
|
||||
{
|
||||
ForEach(convos)
|
||||
{ convo in
|
||||
NavigationLink(value: convo.peerName)
|
||||
{
|
||||
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") }
|
||||
}
|
||||
}
|
||||
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") }
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onChange(of: engine.messages.count) { scrollToLast(proxy) }
|
||||
.onAppear { scrollToLast(proxy) }
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
composeBar
|
||||
}
|
||||
.navigationTitle(t("ios.tab.messages"))
|
||||
.navigationDestination(for: String.self) { peer in ConversationView(peerName: peer) }
|
||||
.toolbar
|
||||
{
|
||||
if !engine.messages.isEmpty
|
||||
@@ -77,12 +62,124 @@ struct MessagesView: View
|
||||
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 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 = ordered.last else { return }
|
||||
guard let last = thread.last else { return }
|
||||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||
}
|
||||
|
||||
@@ -92,18 +189,12 @@ struct MessagesView: View
|
||||
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)
|
||||
}
|
||||
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)
|
||||
@@ -112,53 +203,38 @@ struct MessagesView: View
|
||||
@ViewBuilder
|
||||
private var composeBar: some View
|
||||
{
|
||||
VStack(spacing: 6)
|
||||
if reachable
|
||||
{
|
||||
if targets.isEmpty
|
||||
HStack(spacing: 10)
|
||||
{
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
.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)
|
||||
guard !text.isEmpty else { return }
|
||||
engine.sendMessage(to: peerName, text: text)
|
||||
draft = ""
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user