Neuron®
The Neuron® is the basis for the creation of open and secure federated networks for smart societies.
Loading...
Searching...
No Matches
ContactListViewModel.cs
1using CommunityToolkit.Mvvm.ComponentModel;
2using EDaler;
3using EDaler.Uris;
9using System.Collections.ObjectModel;
10using System.Globalization;
11using System.Text;
12using System.ComponentModel;
19using CommunityToolkit.Mvvm.Input;
22using NeuroAccessMaui.UI.MVVM; // For ObservableTask
23using NeuroAccessMaui.UI.MVVM.Building; // For ObservableTaskBuilder
25using System.Runtime.CompilerServices; // For Policies.Debounce
26
28{
29 public class GroupedContacts
30 {
31 public char Group { get; set; }
32 public ObservableCollection<ContactInfoModel?> Contacts { get; set; }
33
34 public GroupedContacts(char Group)
35 {
36 this.Group = Group;
37 this.Contacts = [];
38 }
39 }
40
44 public partial class ContactListViewModel : BaseViewModel
45 {
46 private readonly Dictionary<CaseInsensitiveString, List<ContactInfoModel>> byBareJid;
47 private readonly TaskCompletionSource<ContactInfoModel?>? selection;
48 private readonly ContactListNavigationArgs? navigationArguments;
49
50 // Debounced search filtering using ObservableTask (no manual CTS needed)
51 private readonly ObservableTask<int> searchFilterTask;
52
53 // Loader task reporting running state while contacts are being loaded.
54 private readonly ObservableTask<int> contactsLoader;
55
59 public ObservableTask<int> ContactsLoader => this.contactsLoader;
60
61 [ObservableProperty]
62 private bool isViewMode = false;
63
64 private string? searchText; // backing field
68 public string? SearchText
69 {
70 get => this.searchText;
71 set
72 {
73 if (this.searchText != value)
74 {
75 this.searchText = value;
76 this.OnPropertyChanged(new PropertyChangedEventArgs(nameof(this.SearchText)));
77 // Trigger debounced filtering
78 this.searchFilterTask.Run();
79 }
80 }
81 }
82
88 {
89 this.navigationArguments = Args;
90 this.Contacts = [];
91 this.byBareJid = [];
92
93 if (Args is not null)
94 {
95 this.Description = Args.Description;
96 this.Action = Args.Action;
97 this.selection = Args.Selection;
98 this.CanScanQrCode = Args.CanScanQrCode;
99 this.AllowAnonymous = Args.AllowAnonymous;
100 this.AnonymousText = string.IsNullOrEmpty(Args.AnonymousText) ?
102 }
103 else
104 {
105 this.Description = ServiceRef.Localizer[nameof(AppResources.ContactsDescription)];
106 this.Action = SelectContactAction.View;
107 this.selection = null;
108 }
109
110 if (this.Action == SelectContactAction.View)
111 this.IsViewMode = true;
112
113 // debounced search task
114 this.searchFilterTask = new ObservableTaskBuilder<int>()
115 .Named("Contacts Search Filter")
116 .WithPolicy(Policies.Debounce(TimeSpan.FromMilliseconds(300)))
117 .AutoStart(false)
118 .UseTaskRun(false)
119 .Run(async ctx =>
120 {
121 await MainThread.InvokeOnMainThreadAsync(this.FilterContacts);
122 })
123 .Build();
124
125 // contacts loader task
126 this.contactsLoader = new ObservableTaskBuilder<int>()
127 .Named("Contacts Loader")
128 .AutoStart(false)
129 .UseTaskRun(false)
130 .Run(async ctx =>
131 {
132 await this.UpdateContactList(this.navigationArguments?.Contacts);
133 await MainThread.InvokeOnMainThreadAsync(this.FilterContacts);
134 })
135 .Build();
136 }
137
139 public override async Task OnInitializeAsync()
140 {
141 await base.OnInitializeAsync();
142
143 // Run loader task to populate contacts; activity indicator bound to ContactsLoader.IsRunning will show during this.
144 this.contactsLoader.Run();
145 await this.contactsLoader.WaitAllAsync();
146
147 ServiceRef.XmppService.OnPresenceSubscribe += this.Xmpp_OnPresence;
148 ServiceRef.XmppService.OnPresenceUnsubscribed += this.Xmpp_OnPresence;
149 ServiceRef.XmppService.OnPresence += this.Xmpp_OnPresence;
150 ServiceRef.NotificationService.OnNewNotification += this.NotificationService_OnNewNotification;
151 ServiceRef.NotificationService.OnNotificationsDeleted += this.NotificationService_OnNotificationsDeleted;
152 }
153
155 public override async Task OnAppearingAsync()
156 {
157 await base.OnAppearingAsync();
158
159 if (this.selection is not null && this.selection.Task.IsCompleted)
160 {
161 await this.GoBack();
162 return;
163 }
164
165 this.SelectedContact = null;
166 }
167
168 private async Task UpdateContactList(IEnumerable<ContactInfo>? Contacts)
169 {
170 SortedDictionary<CaseInsensitiveString, ContactInfo> Sorted = [];
171 Dictionary<CaseInsensitiveString, bool> Jids = [];
172
173 Contacts ??= await Database.Find<ContactInfo>();
174
175 foreach (ContactInfo Info in Contacts)
176 {
177 Jids[Info.BareJid] = true;
178
179 if (Info.IsThing.HasValue && Info.IsThing.Value) // Include those with IsThing=null
180 continue;
181
182 if (Info.AllowSubscriptionFrom.HasValue && !Info.AllowSubscriptionFrom.Value)
183 continue;
184
185 Add(Sorted, Info.FriendlyName, Info);
186 }
187
188 foreach (RosterItem Item in ServiceRef.XmppService.Roster)
189 {
190 if (Jids.ContainsKey(Item.BareJid))
191 continue;
192
193 ContactInfo Info = new()
194 {
195 BareJid = Item.BareJid,
196 FriendlyName = Item.NameOrBareJid,
197 IsThing = null
198 };
199
200 await Database.Insert(Info);
201
202 Add(Sorted, Info.FriendlyName, Info);
203 }
204
205 NotificationEvent[]? Events;
206
207 this.Contacts.Clear();
208 this.ShowContactsMissing = Sorted.Count == 0;
209
211 {
213 {
214 if (Sorted.TryGetValue(Category, out ContactInfo? Info))
215 Sorted.Remove(Category);
216 else
217 {
218 Info = await ContactInfo.FindByBareJid(Category);
219
220 if (Info is not null)
221 Remove(Sorted, Info.FriendlyName, Info);
222 else
223 {
224 Info = new()
225 {
226 BareJid = Category,
227 FriendlyName = Category,
228 IsThing = null
229 };
230 }
231 }
232
233 this.Contacts.Add(new ContactInfoModel(Info, Events));
234 }
235 }
236
237 foreach (ContactInfo Info in Sorted.Values)
238 {
240 Events = [];
241
242 this.Contacts.Add(new ContactInfoModel(Info, Events));
243 }
244
245 this.byBareJid.Clear();
246
247 foreach (ContactInfoModel? Contact in this.Contacts)
248 {
249 if (string.IsNullOrEmpty(Contact?.BareJid))
250 continue;
251
252 if (!this.byBareJid.TryGetValue(Contact.BareJid, out List<ContactInfoModel>? Contacts2))
253 {
254 Contacts2 = [];
255 this.byBareJid[Contact.BareJid] = Contacts2;
256 }
257
258 Contacts2.Add(Contact);
259 }
260 }
261
262 private void FilterContacts()
263 {
264 this.FilteredContacts.Clear();
265 if (this.Contacts.Count == 0)
266 return;
267
268 Dictionary<char, List<ContactInfoModel>> Groups = [];
269 string Search = (this.SearchText ?? string.Empty).Trim();
270 bool HasSearch = !string.IsNullOrEmpty(Search);
271
272 foreach (ContactInfoModel? Contact in this.Contacts)
273 {
274 if (Contact is null)
275 continue;
276
277 if (HasSearch)
278 {
279 string Name = Contact.FriendlyName ?? string.Empty;
280 if (Name.IndexOf(Search, StringComparison.CurrentCultureIgnoreCase) < 0)
281 continue;
282 }
283
284 char Group = string.IsNullOrEmpty(Contact?.FriendlyName) ? '#' : Contact.FriendlyName.Substring(0, 1).ToUpper(CultureInfo.CurrentCulture)[0];
285 if (!char.IsLetter(Group))
286 Group = '#';
287
288 if (!Groups.TryGetValue(Group, out List<ContactInfoModel>? list))
289 {
290 list = new List<ContactInfoModel>();
291 Groups[Group] = list;
292 }
293
294 list.Add(Contact!);
295 }
296
297 foreach (char Group in Groups.Keys.OrderBy(s => s))
298 {
299 GroupedContacts ContactGroup = new(Group);
300
301 foreach (ContactInfoModel Contact in Groups[Group].OrderBy(Contact => Contact.FriendlyName))
302 ContactGroup.Contacts.Add(Contact);
303
304 this.FilteredContacts.Add(ContactGroup);
305 }
306 }
307
308 private static void Add(SortedDictionary<CaseInsensitiveString, ContactInfo> Sorted, CaseInsensitiveString Name, ContactInfo Info)
309 {
310 if (Sorted.ContainsKey(Name))
311 {
312 int i = 1;
313 string Suffix;
314
315 do
316 {
317 Suffix = " " + (++i).ToString(CultureInfo.InvariantCulture);
318 }
319 while (Sorted.ContainsKey(Name + Suffix));
320
321 Sorted[Name + Suffix] = Info;
322 }
323 else
324 Sorted[Name] = Info;
325 }
326
327 private static void Remove(SortedDictionary<CaseInsensitiveString, ContactInfo> Sorted, CaseInsensitiveString Name, ContactInfo Info)
328 {
329 int i = 1;
330 string Suffix = string.Empty;
331
332 while (Sorted.TryGetValue(Name + Suffix, out ContactInfo? Info2))
333 {
334 if (Info2.BareJid == Info.BareJid &&
335 Info2.SourceId == Info.SourceId &&
336 Info2.Partition == Info.Partition &&
337 Info2.NodeId == Info.NodeId &&
338 Info2.LegalId == Info.LegalId)
339 {
340 Sorted.Remove(Name + Suffix);
341
342 i++;
343 string Suffix2 = " " + i.ToString(CultureInfo.InvariantCulture);
344
345 while (Sorted.TryGetValue(Name + Suffix2, out Info2))
346 {
347 Sorted[Name + Suffix] = Info2;
348 Sorted.Remove(Name + Suffix2);
349
350 i++;
351 Suffix2 = " " + i.ToString(CultureInfo.InvariantCulture);
352 }
353
354 return;
355 }
356
357 i++;
358 Suffix = " " + i.ToString(CultureInfo.InvariantCulture);
359 }
360 }
361
362 private bool CanSelectContact => !this.IsViewMode;
363
364 [RelayCommand(CanExecute = nameof(CanSelectContact))]
365 private Task SelectContact(ContactInfoModel? Contact)
366 {
367 if (Contact is not null)
368 {
369 this.SelectedContact = Contact;
370 }
371
372 return Task.CompletedTask;
373 }
374
376 public override Task OnDisposeAsync()
377 {
378 ServiceRef.XmppService.OnPresence -= this.Xmpp_OnPresence;
379 ServiceRef.NotificationService.OnNewNotification -= this.NotificationService_OnNewNotification;
380 ServiceRef.NotificationService.OnNotificationsDeleted -= this.NotificationService_OnNotificationsDeleted;
381
382 if (this.Action != SelectContactAction.Select)
383 {
384 this.ShowContactsMissing = false;
385 this.Contacts.Clear();
386 }
387
388 this.selection?.TrySetResult(this.SelectedContact);
389 this.contactsLoader.Dispose();
390 this.searchFilterTask.Dispose();
391
392 return base.OnDisposeAsync();
393 }
394
398 [ObservableProperty]
399 private bool showContactsMissing;
400
404 [ObservableProperty]
405 private string? description;
406
410 [ObservableProperty]
411 private bool canScanQrCode;
412
416 [ObservableProperty]
417 private bool allowAnonymous;
418
422 [ObservableProperty]
423 private string? anonymousText;
424
428 [ObservableProperty]
429 private SelectContactAction? action;
430
434 public ObservableCollection<ContactInfoModel?> Contacts { get; }
435
436 public ObservableCollection<GroupedContacts> FilteredContacts { get; } = new();
437
441 [ObservableProperty]
442 private ContactInfoModel? selectedContact;
443
445 protected override void OnPropertyChanged(PropertyChangedEventArgs e)
446 {
447 base.OnPropertyChanged(e);
448
449 switch (e.PropertyName)
450 {
451 case nameof(this.SelectedContact):
452 ContactInfoModel? Contact = this.SelectedContact;
453
454 if (Contact is not null)
455 {
456 MainThread.BeginInvokeOnMainThread(async () =>
457 {
458 this.IsOverlayVisible = true;
459
460 try
461 {
462 switch (this.Action)
463 {
464 case SelectContactAction.MakePayment:
465 StringBuilder sb = new();
466
467 sb.Append("edaler:");
468
469 if (ServiceRef.TagProfile.LegalIdentity is null)
470 {
471 sb.Append("f=");
472 sb.Append(ServiceRef.XmppService.BareJid);
473 }
474 else
475 {
476 sb.Append("fi=");
477 sb.Append(ServiceRef.TagProfile.LegalIdentity.Id);
478 }
479
480 if (!string.IsNullOrEmpty(Contact.LegalId))
481 {
482 sb.Append(";ti=");
483 sb.Append(Contact.LegalId);
484 }
485 else if (!string.IsNullOrEmpty(Contact.BareJid))
486 {
487 sb.Append(";t=");
488 sb.Append(Contact.BareJid);
489 }
490
491 Balance Balance = await ServiceRef.XmppService.GetEDalerBalance();
492
493 sb.Append(";cu=");
494 sb.Append(Balance.Currency);
495
496 if (!EDalerUri.TryParse(sb.ToString(), out EDalerUri Parsed))
497 break;
498
499 EDalerUriNavigationArgs Args = new(Parsed);
500 // Inherit the back method here from the parrent
502
503 break;
504
505 case SelectContactAction.View:
506 default:
507 if (Contact.LegalIdentity is not null)
508 {
509 ViewIdentityNavigationArgs ViewIdentityArgs = new(Contact.LegalIdentity);
510
511 await ServiceRef.NavigationService.GoToAsync(nameof(ViewIdentityPage), ViewIdentityArgs);
512 }
513 else if (!string.IsNullOrEmpty(Contact.LegalId))
514 {
515 await ServiceRef.ContractOrchestratorService.OpenLegalIdentity(Contact.LegalId,
517 }
518 else if (!string.IsNullOrEmpty(Contact.BareJid) && Contact.Contact is not null)
519 {
520 ChatNavigationArgs ChatArgs = new(Contact.Contact);
521 await ServiceRef.NavigationService.GoToAsync(nameof(ChatPage), ChatArgs, BackMethod.Inherited, Contact.BareJid);
522 }
523
524 break;
525
526 case SelectContactAction.Select:
527 this.SelectedContact = Contact;
528 await this.GoBack();
529 this.selection?.TrySetResult(Contact);
530 break;
531 }
532 }
533 finally
534 {
535 this.IsOverlayVisible = false;
536 }
537 });
538 }
539 break;
540 }
541 }
542
546 [RelayCommand]
547 private async Task ScanQrCode()
548 {
549 string? Code = await QrCode.ScanQrCode(nameof(AppResources.ScanQRCode), [Constants.UriSchemes.IotId]);
550 if (string.IsNullOrEmpty(Code))
551 return;
552
554 {
555 this.SelectedContact = new ContactInfoModel(new ContactInfo()
556 {
557 LegalId = Constants.UriSchemes.RemoveScheme(Code)
558 });
559 }
560 else if (!string.IsNullOrEmpty(Code))
561 {
564 }
565 }
566
570 [RelayCommand]
571 private void Anonymous()
572 {
573 this.SelectedContact = new ContactInfoModel(null, []);
574 }
575
576 private Task Xmpp_OnPresence(object? Sender, PresenceEventArgs e)
577 {
578 if (this.byBareJid.TryGetValue(e.FromBareJID, out List<ContactInfoModel>? Contacts))
579 {
580 foreach (ContactInfoModel Contact in Contacts)
581 Contact.PresenceUpdated();
582 }
583
584 return Task.CompletedTask;
585 }
586
587 private Task NotificationService_OnNewNotification(object? Sender, NotificationEventArgs e)
588 {
589 if (e.Event.Type == NotificationEventType.Contacts)
590 this.UpdateNotifications(e.Event.Category ?? string.Empty);
591
592 return Task.CompletedTask;
593 }
594
595 private void UpdateNotifications()
596 {
598 this.UpdateNotifications(Category);
599 }
600
601 private void UpdateNotifications(CaseInsensitiveString Category)
602 {
603 if (this.byBareJid.TryGetValue(Category, out List<ContactInfoModel>? Contacts))
604 {
606 Events = [];
607
608 foreach (ContactInfoModel Contact in Contacts)
609 Contact.NotificationsUpdated(Events);
610 }
611 }
612
613 private Task NotificationService_OnNotificationsDeleted(object? Sender, NotificationEventsArgs e)
614 {
615 Dictionary<CaseInsensitiveString, bool> Categories = [];
616
617 foreach (NotificationEvent Event in e.Events)
618 Categories[Event.Category ?? string.Empty] = true;
619
620 foreach (CaseInsensitiveString Category in Categories.Keys)
621 this.UpdateNotifications(Category);
622
623 return Task.CompletedTask;
624 }
625 }
626}
Contains information about a balance.
Definition: Balance.cs:11
CaseInsensitiveString Currency
Currency of amount.
Definition: Balance.cs:54
Abstract base class for eDaler URIs
Definition: EDalerUri.cs:14
static bool TryParse(string Uri, out EDalerUri Result)
Tries to parse an eDaler URI
Definition: EDalerUri.cs:192
static bool StartsWithIdScheme(string Url)
Checks if the specified code starts with the IoT ID scheme.
Definition: Constants.cs:231
static ? string RemoveScheme(string Url)
Removes the URI Schema from an URL.
Definition: Constants.cs:272
const string IotId
The IoT ID URI Scheme (iotid)
Definition: Constants.cs:153
A set of never changing property constants and helpful values.
Definition: Constants.cs:24
A strongly-typed resource class, for looking up localized strings, etc.
static string Anonymous
Looks up a localized string similar to Anonymous.
static string ScanQRCode
Looks up a localized string similar to Scan QR Code.
static string ContactsDescription
Looks up a localized string similar to Below are all contacts in your contact book....
static string ScannedQrCode
Looks up a localized string similar to Scanned QR Code.
static string TheSpecifiedCodeIsNotALegalIdentity
Looks up a localized string similar to You need to scan a different type of QR Code,...
static string ErrorTitle
Looks up a localized string similar to An error has occurred.
Contains information about a contact.
Definition: ContactInfo.cs:22
bool? AllowSubscriptionFrom
Allow subscriptions from this contact
Definition: ContactInfo.cs:146
static Task< ContactInfo > FindByBareJid(string BareJid)
Finds information about a contact, given its Bare JID.
Definition: ContactInfo.cs:221
CaseInsensitiveString LegalId
Legal ID of contact.
Definition: ContactInfo.cs:72
CaseInsensitiveString BareJid
Bare JID of contact.
Definition: ContactInfo.cs:63
Base class that references services in the app.
Definition: ServiceRef.cs:43
static IUiService UiService
Service serializing and managing UI-related tasks.
Definition: ServiceRef.cs:130
static INavigationService NavigationService
The navigation service for navigating between pages.
Definition: ServiceRef.cs:178
static INotificationService NotificationService
Service for managing notifications for the user.
Definition: ServiceRef.cs:334
static ITagProfile TagProfile
TAG Profile service.
Definition: ServiceRef.cs:202
static IContractOrchestratorService ContractOrchestratorService
Contract orchestrator service.
Definition: ServiceRef.cs:238
static IReportingStringLocalizer Localizer
Localization service
Definition: ServiceRef.cs:370
static IXmppService XmppService
The XMPP service for XMPP communication.
Definition: ServiceRef.cs:190
Helper class to perform scanning of QR Codes by displaying the UI and handling async results.
Definition: QrCode.cs:20
static async Task< string?> ScanQrCode(string? QrTitle, string[] AllowedSchemas)
Navigates to the Scan QR Code Page, waits for scan to complete, and returns the result....
Definition: QrCode.cs:191
Provides a data-binding friendly mechanism to manage and report the status of asynchronous operations...
A base class for all view models, inheriting from the BindableObject. NOTE: using this class requir...
virtual async Task GoBack()
Method called when user wants to navigate to the previous screen.
Holds navigation parameters specific to views displaying a list of contacts.
A page that displays a list of the current user's contacts.
Contact Information model, including related notification information.
CaseInsensitiveString? LegalId
Legal ID of contact.
ContactInfo? Contact
Contact Information object in database.
CaseInsensitiveString? BareJid
Bare JID of contact.
Holds navigation parameters specific to views displaying a list of contacts.
SelectContactAction? Action
Action to take when a contact has been selected.
TaskCompletionSource< ContactInfoModel?>? Selection
Selection source, if selecting identity.
bool AllowAnonymous
If user is allowed to select an Anonymous option.
The view model to bind to when displaying the list of contacts.
override async Task OnAppearingAsync()
Method called when view is appearing on the screen.
ObservableTask< int > ContactsLoader
Exposes the loader responsible for loading contacts. Bind to ObservableTask<TProgress>....
string? SearchText
Text used for filtering contacts (search box).
override Task OnDisposeAsync()
Method called when the view is disposed, and will not be used more. Use this method to unregister eve...
override async Task OnInitializeAsync()
Method called when view is initialized for the first time. Use this method to implement registration ...
ContactListViewModel(ContactListNavigationArgs? Args)
Creates an instance of the ContactListViewModel class.
ObservableCollection< ContactInfoModel?> Contacts
Holds the list of contacts to display.
A page to display when the user wants to view an identity.
Holds navigation parameters specific to eDaler URIs.
A page that allows the user to realize payments.
Event arguments for presence events.
string FromBareJID
Bare JID of resource sending the presence.
Maintains information about an item in the roster.
Definition: RosterItem.cs:75
string NameOrBareJid
Returns the name of the contact, or the Bare JID, if there's no name provided.
Definition: RosterItem.cs:436
string BareJid
Bare JID of the roster item.
Definition: RosterItem.cs:276
Represents a case-insensitive string.
Static interface for database persistence. In order to work, a database provider has to be assigned t...
Definition: Database.cs:21
static Task< IEnumerable< object > > Find(string Collection, params string[] SortOrder)
Finds objects in a given collection.
Definition: Database.cs:238
static async Task Insert(object Object)
Inserts an object into the default collection of the database.
Definition: Database.cs:97
bool TryGetNotificationEvents(NotificationEventType Type, CaseInsensitiveString Category, [NotNullWhen(true)] out NotificationEvent[]? Events)
Tries to get available notification events.
CaseInsensitiveString[] GetCategories(NotificationEventType Type)
Gets available categories for a button.
Task GoToAsync(string Route)
Navigates to the specified route and pushes the page onto the navigation stack.
Task< bool > DisplayAlert(string Title, string Message, string? Accept=null, string? Cancel=null)
Displays an alert/message box to the user.
abstract class NotificationEvent()
Abstract base class of notification events.
class NotificationEventsArgs(NotificationEvent[] Events)
Event argument for notification events.
class NotificationEventArgs(NotificationEvent Event)
Event argument for notification events.
NotificationEventType
Button on which event is to be displayed.
BackMethod
Navigation Back Method
Definition: BackMethod.cs:7
SelectContactAction
Actions to take when a contact has been selected.
delegate string ToString(IElement Element)
Delegate for callback methods that convert an element value to a string.