using System.Collections.ObjectModel;
using System.Text.Json;
using JiDangJia.Contracts;
using JiDangJia.Services;
namespace JiDangJia.Views;
public sealed class ConversationsPage : OnlinePage
{
private readonly VerticalStackLayout list=Ui.Stack();
private string signature="";
public ConversationsPage():base(8)
{
Title="账户私聊";Content=CommunityUi.Layout(Ui.Stack(Ui.Text("私信",23,bold:true),Ui.Text("无需加好友,直接与账户交流。",12,Ui.Muted),Ui.Spread(Ui.Button("+ 新建私信",()=>Navigation.PushAsync(new ContactsPage())),Ui.Button("刷新",Reload,false)),State),new ScrollView{Content=list});
}
private void Local()
{
var data=CommunityChatArchive.Current.Conversations();var current=JsonSerializer.Serialize(data);if(current==signature)return;signature=current;list.Clear();if(data.Count==0)list.Add(Ui.Empty("还没有私信","选择账户发送第一条消息"));foreach(var row in data)list.Add(CommunityUi.ConversationCard(row,this));
}
protected override async Task LoadData(){Local();await CommunityChatArchive.Sync();if(CanUpdate)Local();}
}
public sealed class ContactsPage : OnlinePage
{
private readonly bool followed;
private readonly Entry search=new(){Placeholder="搜索账户 / 姓名 / 备注",MaxLength=64,ClearButtonVisibility=ClearButtonVisibility.WhileEditing};
private readonly VerticalStackLayout list=Ui.Stack();
public ContactsPage(bool followed=false)
{
this.followed=followed;Title=followed?"管理关注":"选择聊天账户";Content=CommunityUi.Layout(Ui.Stack(Ui.Spread(Ui.Card(search,12,new Thickness(12,0)),Ui.Button("搜索",Reload,false)),Ui.Text("显示前 50 个匹配账户,可按账户名准确搜索。",11,Ui.Muted),State),new ScrollView{Content=list});search.Completed+=(_,_)=>_=Reload();
}
protected override async Task LoadData()
{
var rows=await Auth.CommunityRequest<List<CommunityAccount>>((followed?"/follows":"/contacts")+"?q="+Uri.EscapeDataString(search.Text?.Trim()??""));if(!CanUpdate)return;list.Clear();if(rows.Count==0)list.Add(Ui.Empty("没有匹配账户",followed?"关注同行后可设置自己的备注":"请更换姓名或账户名搜索"));
foreach(var row in rows){var body=Ui.Stack(Ui.Spread(CommunityUi.Person(CommunityUi.Name(row),"@"+row.Username),CommunitySafetyUi.Badge(row.IdentityKind,row.Verified,row.CreditLevel)),Ui.Spread(Ui.Button(row.Following?"取消关注":"关注",async()=>{await CommunityUi.Follow(this,row);await Reload();},false),Ui.Button("私信",()=>CommunitySafetyUi.OpenChat(this,row))));if(row.Following)body.Add(Ui.Button("设置备注",async()=>{await CommunityUi.Follow(this,row,true);await Reload();},false));list.Add(Ui.Card(body));}
}
}
public sealed class ChatPage : OnlinePage
{
private CommunityAccount peer;
private readonly ObservableCollection<ChatMessage> messages=[];
private readonly CollectionView list;
private readonly Editor text=new(){Placeholder="输入消息…",MaxLength=2000,HeightRequest=44,AutoSize=EditorAutoSizeOption.TextChanges,MaximumHeightRequest=100,FontSize=14};
private readonly Grid attachmentPanel=new(){ColumnDefinitions=[new(GridLength.Star),new(GridLength.Star),new(GridLength.Star),new(GridLength.Star)],RowDefinitions=[new(GridLength.Auto)],ColumnSpacing=10,Padding=new Thickness(5,12),IsVisible=false};
private readonly ChatVoiceInput voice;
private readonly ContentView composer=new();
private readonly Button sendButton,modeButton;
private bool voiceMode;
private Window? voiceWindow;
private readonly Button older;
private readonly VerticalStackLayout identity=Ui.Stack();
private long cursor,readCursor;
private bool initialized,atBottom=true;
private bool firstScroll=true;
private int bottomVersion;
private string clearedAt="";
private string nonce=Guid.NewGuid().ToString("N"),attempt="";
private (CommunityMedia media,string nonce)? pending;
public ChatPage(CommunityAccount peer):base(3)
{
this.peer=peer;Title=CommunityUi.Name(peer);older=Ui.Button("加载更早消息",Older,false);older.IsVisible=false;
list=new CollectionView{ItemsSource=messages,SelectionMode=SelectionMode.None,ItemsUpdatingScrollMode=ItemsUpdatingScrollMode.KeepItemsInView,ItemTemplate=new DataTemplate(()=>new ChatBubble(Auth.AccountId,this)),EmptyView=Ui.Empty("开始您的对话","图文、语音或视频,与同行直接交流")};list.Scrolled+=(_,e)=>{if(!firstScroll)atBottom=e.LastVisibleItemIndex>=messages.Count-2;};
list.Loaded+=(_,_)=>{if(firstScroll&&messages.Count>0)Bottom();};list.SizeChanged+=(_,_)=>{if(atBottom&&messages.Count>0)Bottom();};
voice=new(this,SendMedia,message=>State.Text=message);composer.Content=text;
sendButton=Small("发送",Send,true);modeButton=Small("语音",ToggleVoice);
var input=new Grid{ColumnDefinitions=[new(GridLength.Auto),new(GridLength.Star),new(GridLength.Auto),new(GridLength.Auto)],ColumnSpacing=6};input.Add(modeButton);input.Add(composer,1);input.Add(sendButton,2);input.Add(Small("+",()=>{attachmentPanel.IsVisible=!attachmentPanel.IsVisible;return Task.CompletedTask;}),3);
var actions=new[]{("相册","archive"),("拍摄","camera"),("互换证照","document"),("更多","grid")};
for(int i=0;i<actions.Length;i++){var action=actions[i];var label=Ui.Text(action.Item1,11,Ui.Muted);label.HorizontalTextAlignment=TextAlignment.Center;var item=Ui.Stack(Ui.Card(Ui.Icon(action.Item2,Ui.Bronze,23),12,new Thickness(12),Colors.White),label);item.Spacing=5;attachmentPanel.Add(Ui.Tap(item,()=>Attachment(action.Item1),action.Item1),i);}
Identity();var bottom=Ui.Stack(input,attachmentPanel);bottom.Spacing=0;
var root=CommunityUi.Layout(Ui.Stack(identity,State,older),list,bottom);root.Padding=new Thickness(12,8,12,8);root.RowSpacing=6;Content=root;
}
private static Button Small(string name,Func<Task> action,bool primary=false){var b=Ui.Button(name,action,primary);b.HeightRequest=40;b.FontSize=12;b.Padding=new Thickness(10,0);b.CornerRadius=10;return b;}
private Task ToggleVoice(){voiceMode=!voiceMode;composer.Content=voiceMode?voice:text;modeButton.Text=voiceMode?"键盘":"语音";sendButton.IsVisible=!voiceMode;attachmentPanel.IsVisible=false;if(voiceMode)voice.Start();else voice.Stop();return Task.CompletedTask;}
private void Identity()
{
identity.Clear();identity.Add(Ui.Spread(CommunitySafetyUi.Badge(peer.IdentityKind,peer.Verified,peer.CreditLevel),Small("聊天设置",ChatSettings)));var tip=Ui.Text("核验交易对象与实物,谨慎处理陌生转账与链接。",10,Ui.Muted);identity.Add(Ui.Tap(tip,()=>DisplayAlertAsync("温馨提示","请核验交易对象、商品及收款信息,勿透露验证码。遇到异常请从聊天设置举报。","知道了"),"温馨提示"));identity.Spacing=4;
}
protected override void OnAppearing(){base.OnAppearing();voiceWindow=Window;if(voiceWindow is not null){voiceWindow.Stopped+=StopVoice;voiceWindow.Resumed+=ResumeVoice;}if(voiceMode)voice.Start();}
protected override void OnDisappearing(){bottomVersion++;voice.Stop();if(voiceWindow is not null){voiceWindow.Stopped-=StopVoice;voiceWindow.Resumed-=ResumeVoice;}voiceWindow=null;base.OnDisappearing();}
private void StopVoice(object? sender,EventArgs args)=>voice.Stop();
private void ResumeVoice(object? sender,EventArgs args){if(voiceMode)voice.Start();}
private async Task ChatSettings(){var options=new List<string>{peer.Following?"取消关注":"关注","互换证照","举报用户","刷新消息"};if(peer.Following)options.Insert(1,"设置备注");var action=await DisplayActionSheetAsync("聊天设置","取消",null,options.ToArray());switch(action){case "关注":case "取消关注":peer=await CommunityUi.Follow(this,peer);Identity();break;case "设置备注":peer=await CommunityUi.Follow(this,peer,true);Title=CommunityUi.Name(peer);break;case "互换证照":await ExchangePage.Request(this,peer.Id);break;case "举报用户":await Navigation.PushAsync(new ReportPage(peer.Id));break;case "刷新消息":await Reload();break;}}
protected override async Task LoadData()
{
Local();await CommunityChatArchive.Sync();if(CanUpdate)Local();
}
private void Local()
{
var local=CommunityChatArchive.Current;if(clearedAt!=local.ClearedAt){clearedAt=local.ClearedAt;messages.Clear();cursor=readCursor=0;initialized=false;firstScroll=true;}
bool first=!initialized;initialized=true;
var rows=first?local.Messages(peer.Id):local.Messages(peer.Id,after:cursor,limit:500);
foreach(var item in rows){var old=messages.FirstOrDefault(m=>m.Id==item.Id);if(old is null)messages.Add(item);else if(old!=item)messages[messages.IndexOf(old)]=item;}
if(rows.Count>0)cursor=Math.Max(cursor,rows.Max(m=>m.Id));older.IsVisible=messages.Count>0&&local.HasEarlier(peer.Id,messages[0].Id);
if(first||atBottom){if(rows.Count>0)Bottom();Read();}
}
private void Bottom()
{
atBottom=true;int version=++bottomVersion;
// Android cannot scroll to a just-bound item until the collection has a measured viewport.
Dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(200),()=>{if(version!=bottomVersion||!CanUpdate||!atBottom||messages.Count==0)return;list.ScrollTo(messages[^1],position:ScrollToPosition.End,animate:false);firstScroll=false;});
}
private void Read(){if(cursor<=readCursor)return;var id=messages.Where(m=>m.ModerationStatus=="APPROVED").Select(m=>m.Id).DefaultIfEmpty().Max();if(id>0)CommunityChatArchive.Current.MarkRead(peer.Id,id);readCursor=cursor;}
private Task Older(){if(messages.Count==0)return Task.CompletedTask;var local=CommunityChatArchive.Current;var data=local.Messages(peer.Id,before:messages[0].Id);for(int i=data.Count-1;i>=0;i--)if(messages.All(m=>m.Id!=data[i].Id))messages.Insert(0,data[i]);older.IsVisible=local.HasEarlier(peer.Id,messages[0].Id);atBottom=false;return Task.CompletedTask;}
private async Task Send(){if(!await CommunitySafetyUi.Write(this))return;var content=text.Text?.Trim()??"";if(content.Length==0)throw new ArgumentException("请输入消息");if(attempt!=content){attempt=content;nonce=Guid.NewGuid().ToString("N");}var sent=await Auth.CommunityRequest<ChatMessage>("/chat/messages",new SendMessageRequest(peer.Id,content,nonce));CommunityChatArchive.SaveSent(sent,peer);if(text.Text?.Trim()==content)text.Text="";attempt="";atBottom=true;await Reload();}
private async Task SendMedia(CommunityMedia media)
{
// A capture-page retry must retain the nonce when the server accepted a message but its reply was lost.
if(pending is not { } previous||previous.media.Id!=media.Id)pending=(media,Guid.NewGuid().ToString("N"));await RetryMedia();
}
private async Task RetryMedia(){if(pending is not { } p)return;var sent=await Auth.CommunityRequest<ChatMessage>("/chat/messages",new SendMessageRequest(peer.Id,"",p.nonce,p.media.Kind,p.media.Id));CommunityChatArchive.SaveSent(sent,peer);pending=null;atBottom=true;await Reload();}
private async Task Attachment(string action)
{
if(!await CommunitySafetyUi.Write(this))return;
attachmentPanel.IsVisible=false;
switch(action)
{
case "重试上次媒体消息":await RetryMedia();break;
case "相册":var album=await DisplayActionSheetAsync("从相册发送","取消",null,"照片","视频(30秒内)");if(album=="照片"){var local=await MediaFiles.Pick(false);if(local is not null)await SendMedia(await Auth.UploadMedia(Convert.FromBase64String(local[(local.IndexOf(',')+1)..]),"IMAGE",Guid.NewGuid().ToString("N"),"LOCAL",DateTimeOffset.Now,true));}else if(album=="视频(30秒内)")await AlbumVideo();break;
case "拍摄":var capture=await DisplayActionSheetAsync("拍摄","取消",null,"拍照","录制视频(30秒内)");if(capture=="拍照"){var photo=await MediaFiles.Pick(true);if(photo is not null)await SendMedia(await Auth.UploadMedia(Convert.FromBase64String(photo[(photo.IndexOf(',')+1)..]),"IMAGE",Guid.NewGuid().ToString("N"),"CAMERA",DateTimeOffset.Now,true));}else if(capture=="录制视频(30秒内)")await CommunityCapturePage.Open(this,"VIDEO",SendMedia);break;
case "录制语音(60秒内)":case "录制视频(30秒内)":await CommunityCapturePage.Open(this,action.Contains("视频")?"VIDEO":"AUDIO",SendMedia);break;
case "互换证照":await ExchangePage.Request(this,peer.Id);break;
case "更多":if(pending is not null)await RetryMedia();else await ChatSettings();break;
}
}
private async Task AlbumVideo()
{
string account=Auth.AccountId;var file=await FilePicker.Default.PickAsync(new PickOptions{PickerTitle="选择 30 秒内的视频",FileTypes=new FilePickerFileType(new Dictionary<DevicePlatform,IEnumerable<string>>{[DevicePlatform.Android]=["video/mp4","video/webm"],[DevicePlatform.WinUI]=[".mp4",".webm"]})});if(file is null)return;var selectedAt=DateTimeOffset.Now;
await using var input=await file.OpenReadAsync();using var data=new MemoryStream();var buffer=new byte[65536];int read;while((read=await input.ReadAsync(buffer))>0){if(data.Length+read>25*1024*1024)throw new ArgumentException("视频最多 25 MB,请选择较小视频");data.Write(buffer,0,read);}if(account!=Auth.AccountId||!CanUpdate)return;
await SendMedia(await Auth.UploadMedia(data.ToArray(),"VIDEO",Guid.NewGuid().ToString("N"),"LOCAL",selectedAt,true));
}
}
internal sealed class ChatBubble(string accountId,ContentPage page) : ContentView
{
protected override void OnBindingContextChanged()
{
base.OnBindingContextChanged();if(BindingContext is not ChatMessage m)return;bool own=m.SenderId==accountId;var body=Ui.Stack(Ui.Text(own?"我":m.SenderName,11,Ui.Muted));
if(m.Media is { } media){View attachment=media.Kind=="IMAGE"?new ProtectedMediaImage(media,160,Aspect.AspectFit,true){WidthRequest=205}:Ui.Row(Ui.Icon(media.Kind=="AUDIO"?"phone":"archive",Ui.Bronze,23),Ui.Text((media.Kind=="AUDIO"?"▶ 语音":"▶ 视频")+$" · {Math.Ceiling(media.Duration)} 秒",14));body.Add(Ui.Tap(attachment,()=>page.Navigation.PushAsync(new CommunityMediaPage(media)),"播放私信"+(media.Kind=="AUDIO"?"语音":media.Kind=="VIDEO"?"视频":"图片")));}
if(m.ExchangeId.Length>0)body.Add(Ui.Button(m.Kind=="CERTIFICATE"?"查看双方信息卡":"查看 / 确认证照互换",()=>page.Navigation.PushAsync(new ExchangePage(m.ExchangeId)),false));if(m.ModerationStatus!="APPROVED")body.Add(Ui.Text(CommunitySafetyUi.Status(m.ModerationStatus),11,Ui.Red));if(m.Content.Length>0)body.Add(Ui.Text(m.Content,14));body.Add(Ui.Text(CommunityUi.Time(m.CreatedAt),10,Ui.Muted));body.Spacing=6;var card=Ui.Card(body,16,new Thickness(14,10),own?Ui.Cream:Colors.White);card.MaximumWidthRequest=285;card.HorizontalOptions=own?LayoutOptions.End:LayoutOptions.Start;var row=new Grid{Padding=new Thickness(0,4)};row.Add(card);Content=row;
}
}
using System.Collections.ObjectModel;
using System.Text.Json;
using JiDangJia.Contracts;
using JiDangJia.Services;
namespace JiDangJia.Views;
public sealed class ConversationsPage : OnlinePage
{
private readonly VerticalStackLayout list=Ui.Stack();
private string signature="";
public ConversationsPage():base(8)
{
Title="账户私聊";Content=CommunityUi.Layout(Ui.Stack(Ui.Text("私信",23,bold:true),Ui.Text("无需加好友,直接与账户交流。",12,Ui.Muted),Ui.Spread(Ui.Button("+ 新建私信",()=>Navigation.PushAsync(new ContactsPage())),Ui.Button("刷新",Reload,false)),State),new ScrollView{Content=list});
}
private void Local()
{
var data=CommunityChatArchive.Current.Conversations();var current=JsonSerializer.Serialize(data);if(current==signature)return;signature=current;list.Clear();if(data.Count==0)list.Add(Ui.Empty("还没有私信","选择账户发送第一条消息"));foreach(var row in data)list.Add(CommunityUi.ConversationCard(row,this));
}
protected override async Task LoadData(){Local();await CommunityChatArchive.Sync();if(CanUpdate)Local();}
}
public sealed class ContactsPage : OnlinePage
{
private readonly bool followed;
private readonly Entry search=new(){Placeholder="搜索账户 / 姓名 / 备注",MaxLength=64,ClearButtonVisibility=ClearButtonVisibility.WhileEditing};
private readonly VerticalStackLayout list=Ui.Stack();
public ContactsPage(bool followed=false)
{
this.followed=followed;Title=followed?"管理关注":"选择聊天账户";Content=CommunityUi.Layout(Ui.Stack(Ui.Spread(Ui.Card(search,12,new Thickness(12,0)),Ui.Button("搜索",Reload,false)),Ui.Text("显示前 50 个匹配账户,可按账户名准确搜索。",11,Ui.Muted),State),new ScrollView{Content=list});search.Completed+=(_,_)=>_=Reload();
}
protected override async Task LoadData()
{
var rows=await Auth.CommunityRequest<List<CommunityAccount>>((followed?"/follows":"/contacts")+"?q="+Uri.EscapeDataString(search.Text?.Trim()??""));if(!CanUpdate)return;list.Clear();if(rows.Count==0)list.Add(Ui.Empty("没有匹配账户",followed?"关注同行后可设置自己的备注":"请更换姓名或账户名搜索"));
foreach(var row in rows){var body=Ui.Stack(Ui.Spread(CommunityUi.Person(CommunityUi.Name(row),"@"+row.Username),CommunitySafetyUi.Badge(row.IdentityKind,row.Verified,row.CreditLevel)),Ui.Spread(Ui.Button(row.Following?"取消关注":"关注",async()=>{await CommunityUi.Follow(this,row);await Reload();},false),Ui.Button("私信",()=>CommunitySafetyUi.OpenChat(this,row))));if(row.Following)body.Add(Ui.Button("设置备注",async()=>{await CommunityUi.Follow(this,row,true);await Reload();},false));list.Add(Ui.Card(body));}
}
}
public sealed class ChatPage : OnlinePage
{
private CommunityAccount peer;
private readonly ObservableCollection<ChatMessage> messages=[];
private readonly CollectionView list;
private readonly Editor text=new(){Placeholder="输入消息…",MaxLength=2000,HeightRequest=44,AutoSize=EditorAutoSizeOption.TextChanges,MaximumHeightRequest=100,FontSize=14};
private readonly Grid attachmentPanel=new(){ColumnDefinitions=[new(GridLength.Star),new(GridLength.Star),new(GridLength.Star),new(GridLength.Star)],RowDefinitions=[new(GridLength.Auto)],ColumnSpacing=10,Padding=new Thickness(5,12),IsVisible=false};
private readonly ChatVoiceInput voice;
private readonly ContentView composer=new();
private readonly Button sendButton,modeButton;
private bool voiceMode;
private Window? voiceWindow;
private readonly Button older;
private readonly VerticalStackLayout identity=Ui.Stack();
private long cursor,readCursor;
private bool initialized,atBottom=true;
private bool firstScroll=true;
private int bottomVersion;
private string clearedAt="";
private string nonce=Guid.NewGuid().ToString("N"),attempt="";
private (CommunityMedia media,string nonce)? pending;
public ChatPage(CommunityAccount peer):base(3)
{
this.peer=peer;Title=CommunityUi.Name(peer);older=Ui.Button("加载更早消息",Older,false);older.IsVisible=false;
list=new CollectionView{ItemsSource=messages,SelectionMode=SelectionMode.None,ItemsUpdatingScrollMode=ItemsUpdatingScrollMode.KeepItemsInView,ItemTemplate=new DataTemplate(()=>new ChatBubble(Auth.AccountId,this)),EmptyView=Ui.Empty("开始您的对话","图文、语音或视频,与同行直接交流")};list.Scrolled+=(_,e)=>{if(!firstScroll)atBottom=e.LastVisibleItemIndex>=messages.Count-2;};
list.Loaded+=(_,_)=>{if(firstScroll&&messages.Count>0)Bottom();};list.SizeChanged+=(_,_)=>{if(atBottom&&messages.Count>0)Bottom();};
voice=new(this,SendMedia,message=>State.Text=message);composer.Content=text;
sendButton=Small("发送",Send,true);modeButton=Small("语音",ToggleVoice);
var input=new Grid{ColumnDefinitions=[new(GridLength.Auto),new(GridLength.Star),new(GridLength.Auto),new(GridLength.Auto)],ColumnSpacing=6};input.Add(modeButton);input.Add(composer,1);input.Add(sendButton,2);input.Add(Small("+",()=>{attachmentPanel.IsVisible=!attachmentPanel.IsVisible;return Task.CompletedTask;}),3);
var actions=new[]{("相册","archive"),("拍摄","camera"),("互换证照","document"),("更多","grid")};
for(int i=0;i<actions.Length;i++){var action=actions[i];var label=Ui.Text(action.Item1,11,Ui.Muted);label.HorizontalTextAlignment=TextAlignment.Center;var item=Ui.Stack(Ui.Card(Ui.Icon(action.Item2,Ui.Bronze,23),12,new Thickness(12),Colors.White),label);item.Spacing=5;attachmentPanel.Add(Ui.Tap(item,()=>Attachment(action.Item1),action.Item1),i);}
Identity();var bottom=Ui.Stack(input,attachmentPanel);bottom.Spacing=0;
var root=CommunityUi.Layout(Ui.Stack(identity,State,older),list,bottom);root.Padding=new Thickness(12,8,12,8);root.RowSpacing=6;Content=root;
}
private static Button Small(string name,Func<Task> action,bool primary=false){var b=Ui.Button(name,action,primary);b.HeightRequest=40;b.FontSize=12;b.Padding=new Thickness(10,0);b.CornerRadius=10;return b;}
private Task ToggleVoice(){voiceMode=!voiceMode;composer.Content=voiceMode?voice:text;modeButton.Text=voiceMode?"键盘":"语音";sendButton.IsVisible=!voiceMode;attachmentPanel.IsVisible=false;if(voiceMode)voice.Start();else voice.Stop();return Task.CompletedTask;}
private void Identity()
{
identity.Clear();identity.Add(Ui.Spread(CommunitySafetyUi.Badge(peer.IdentityKind,peer.Verified,peer.CreditLevel),Small("聊天设置",ChatSettings)));var tip=Ui.Text("核验交易对象与实物,谨慎处理陌生转账与链接。",10,Ui.Muted);identity.Add(Ui.Tap(tip,()=>DisplayAlertAsync("温馨提示","请核验交易对象、商品及收款信息,勿透露验证码。遇到异常请从聊天设置举报。","知道了"),"温馨提示"));identity.Spacing=4;
}
protected override void OnAppearing(){base.OnAppearing();voiceWindow=Window;if(voiceWindow is not null){voiceWindow.Stopped+=StopVoice;voiceWindow.Resumed+=ResumeVoice;}if(voiceMode)voice.Start();}
protected override void OnDisappearing(){bottomVersion++;voice.Stop();if(voiceWindow is not null){voiceWindow.Stopped-=StopVoice;voiceWindow.Resumed-=ResumeVoice;}voiceWindow=null;base.OnDisappearing();}
private void StopVoice(object? sender,EventArgs args)=>voice.Stop();
private void ResumeVoice(object? sender,EventArgs args){if(voiceMode)voice.Start();}
private async Task ChatSettings(){var options=new List<string>{peer.Following?"取消关注":"关注","互换证照","举报用户","刷新消息"};if(peer.Following)options.Insert(1,"设置备注");var action=await DisplayActionSheetAsync("聊天设置","取消",null,options.ToArray());switch(action){case "关注":case "取消关注":peer=await CommunityUi.Follow(this,peer);Identity();break;case "设置备注":peer=await CommunityUi.Follow(this,peer,true);Title=CommunityUi.Name(peer);break;case "互换证照":await ExchangePage.Request(this,peer.Id);break;case "举报用户":await Navigation.PushAsync(new ReportPage(peer.Id));break;case "刷新消息":await Reload();break;}}
protected override async Task LoadData()
{
Local();await CommunityChatArchive.Sync();if(CanUpdate)Local();
}
private void Local()
{
var local=CommunityChatArchive.Current;if(clearedAt!=local.ClearedAt){clearedAt=local.ClearedAt;messages.Clear();cursor=readCursor=0;initialized=false;firstScroll=true;}
bool first=!initialized;initialized=true;
var rows=first?local.Messages(peer.Id):local.Messages(peer.Id,after:cursor,limit:500);
foreach(var item in rows){var old=messages.FirstOrDefault(m=>m.Id==item.Id);if(old is null)messages.Add(item);else if(old!=item)messages[messages.IndexOf(old)]=item;}
if(rows.Count>0)cursor=Math.Max(cursor,rows.Max(m=>m.Id));older.IsVisible=messages.Count>0&&local.HasEarlier(peer.Id,messages[0].Id);
if(first||atBottom){if(rows.Count>0)Bottom();Read();}
}
private void Bottom()
{
atBottom=true;int version=++bottomVersion;
// Android cannot scroll to a just-bound item until the collection has a measured viewport.
Dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(200),()=>{if(version!=bottomVersion||!CanUpdate||!atBottom||messages.Count==0)return;list.ScrollTo(messages[^1],position:ScrollToPosition.End,animate:false);firstScroll=false;});
}
private void Read(){if(cursor<=readCursor)return;var id=messages.Where(m=>m.ModerationStatus=="APPROVED").Select(m=>m.Id).DefaultIfEmpty().Max();if(id>0)CommunityChatArchive.Current.MarkRead(peer.Id,id);readCursor=cursor;}
private Task Older(){if(messages.Count==0)return Task.CompletedTask;var local=CommunityChatArchive.Current;var data=local.Messages(peer.Id,before:messages[0].Id);for(int i=data.Count-1;i>=0;i--)if(messages.All(m=>m.Id!=data[i].Id))messages.Insert(0,data[i]);older.IsVisible=local.HasEarlier(peer.Id,messages[0].Id);atBottom=false;return Task.CompletedTask;}
private async Task Send(){if(!await CommunitySafetyUi.Write(this))return;var content=text.Text?.Trim()??"";if(content.Length==0)throw new ArgumentException("请输入消息");if(attempt!=content){attempt=content;nonce=Guid.NewGuid().ToString("N");}var sent=await Auth.CommunityRequest<ChatMessage>("/chat/messages",new SendMessageRequest(peer.Id,content,nonce));CommunityChatArchive.SaveSent(sent,peer);if(text.Text?.Trim()==content)text.Text="";attempt="";atBottom=true;await Reload();}
private async Task SendMedia(CommunityMedia media)
{
// A capture-page retry must retain the nonce when the server accepted a message but its reply was lost.
if(pending is not { } previous||previous.media.Id!=media.Id)pending=(media,Guid.NewGuid().ToString("N"));await RetryMedia();
}
private async Task RetryMedia(){if(pending is not { } p)return;var sent=await Auth.CommunityRequest<ChatMessage>("/chat/messages",new SendMessageRequest(peer.Id,"",p.nonce,p.media.Kind,p.media.Id));CommunityChatArchive.SaveSent(sent,peer);pending=null;atBottom=true;await Reload();}
private async Task Attachment(string action)
{
if(!await CommunitySafetyUi.Write(this))return;
attachmentPanel.IsVisible=false;
switch(action)
{
case "重试上次媒体消息":await RetryMedia();break;
case "相册":var album=await DisplayActionSheetAsync("从相册发送","取消",null,"照片","视频(30秒内)");if(album=="照片"){var local=await MediaFiles.Pick(false);if(local is not null)await SendMedia(await Auth.UploadMedia(Convert.FromBase64String(local[(local.IndexOf(',')+1)..]),"IMAGE",Guid.NewGuid().ToString("N"),"LOCAL",DateTimeOffset.Now,true));}else if(album=="视频(30秒内)")await AlbumVideo();break;
case "拍摄":var capture=await DisplayActionSheetAsync("拍摄","取消",null,"拍照","录制视频(30秒内)");if(capture=="拍照"){var photo=await MediaFiles.Pick(true);if(photo is not null)await SendMedia(await Auth.UploadMedia(Convert.FromBase64String(photo[(photo.IndexOf(',')+1)..]),"IMAGE",Guid.NewGuid().ToString("N"),"CAMERA",DateTimeOffset.Now,true));}else if(capture=="录制视频(30秒内)")await CommunityCapturePage.Open(this,"VIDEO",SendMedia);break;
case "录制语音(60秒内)":case "录制视频(30秒内)":await CommunityCapturePage.Open(this,action.Contains("视频")?"VIDEO":"AUDIO",SendMedia);break;
case "互换证照":await ExchangePage.Request(this,peer.Id);break;
case "更多":if(pending is not null)await RetryMedia();else await ChatSettings();break;
}
}
private async Task AlbumVideo()
{
string account=Auth.AccountId;var file=await FilePicker.Default.PickAsync(new PickOptions{PickerTitle="选择 30 秒内的视频",FileTypes=new FilePickerFileType(new Dictionary<DevicePlatform,IEnumerable<string>>{[DevicePlatform.Android]=["video/mp4","video/webm"],[DevicePlatform.WinUI]=[".mp4",".webm"]})});if(file is null)return;var selectedAt=DateTimeOffset.Now;
await using var input=await file.OpenReadAsync();using var data=new MemoryStream();var buffer=new byte[65536];int read;while((read=await input.ReadAsync(buffer))>0){if(data.Length+read>25*1024*1024)throw new ArgumentException("视频最多 25 MB,请选择较小视频");data.Write(buffer,0,read);}if(account!=Auth.AccountId||!CanUpdate)return;
await SendMedia(await Auth.UploadMedia(data.ToArray(),"VIDEO",Guid.NewGuid().ToString("N"),"LOCAL",selectedAt,true));
}
}
internal sealed class ChatBubble(string accountId,ContentPage page) : ContentView
{
protected override void OnBindingContextChanged()
{
base.OnBindingContextChanged();if(BindingContext is not ChatMessage m)return;bool own=m.SenderId==accountId;var body=Ui.Stack(Ui.Text(own?"我":m.SenderName,11,Ui.Muted));
if(m.Media is { } media){View attachment=media.Kind=="IMAGE"?new ProtectedMediaImage(media,160,Aspect.AspectFit,true){WidthRequest=205}:Ui.Row(Ui.Icon(media.Kind=="AUDIO"?"phone":"archive",Ui.Bronze,23),Ui.Text((media.Kind=="AUDIO"?"▶ 语音":"▶ 视频")+$" · {Math.Ceiling(media.Duration)} 秒",14));body.Add(Ui.Tap(attachment,()=>page.Navigation.PushAsync(new CommunityMediaPage(media)),"播放私信"+(media.Kind=="AUDIO"?"语音":media.Kind=="VIDEO"?"视频":"图片")));}
if(m.ExchangeId.Length>0)body.Add(Ui.Button(m.Kind=="CERTIFICATE"?"查看双方信息卡":"查看 / 确认证照互换",()=>page.Navigation.PushAsync(new ExchangePage(m.ExchangeId)),false));if(m.ModerationStatus!="APPROVED")body.Add(Ui.Text(CommunitySafetyUi.Status(m.ModerationStatus),11,Ui.Red));if(m.Content.Length>0)body.Add(Ui.Text(m.Content,14));body.Add(Ui.Text(CommunityUi.Time(m.CreatedAt),10,Ui.Muted));body.Spacing=6;var card=Ui.Card(body,16,new Thickness(14,10),own?Ui.Cream:Colors.White);card.MaximumWidthRequest=285;card.HorizontalOptions=own?LayoutOptions.End:LayoutOptions.Start;var row=new Grid{Padding=new Thickness(0,4)};row.Add(card);Content=row;
}
}