Line data Source code
1 : import 'dart:async';
2 :
3 : import 'package:cwtch/main.dart';
4 : import 'package:cwtch/models/groupmembers.dart';
5 : import 'package:cwtch/models/message_draft.dart';
6 : import 'package:cwtch/models/profile.dart';
7 : import 'package:cwtch/models/redaction.dart';
8 : import 'package:cwtch/themes/opaque.dart';
9 : import 'package:cwtch/views/contactsview.dart';
10 : import 'package:cwtch/widgets/messagerow.dart';
11 : import 'package:flutter/material.dart';
12 : import 'package:flutter/widgets.dart';
13 : import 'package:cwtch/l10n/app_localizations.dart';
14 : import 'package:provider/provider.dart';
15 : import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
16 :
17 : import '../settings.dart';
18 : import 'messagecache.dart';
19 :
20 : enum ConversationNotificationPolicy { Default, OptIn, Never }
21 :
22 : extension Nameable on ConversationNotificationPolicy {
23 0 : String toName(BuildContext context) {
24 : switch (this) {
25 0 : case ConversationNotificationPolicy.Default:
26 0 : return AppLocalizations.of(context)!.conversationNotificationPolicyDefault;
27 0 : case ConversationNotificationPolicy.OptIn:
28 0 : return AppLocalizations.of(context)!.conversationNotificationPolicyOptIn;
29 0 : case ConversationNotificationPolicy.Never:
30 0 : return AppLocalizations.of(context)!.conversationNotificationPolicyNever;
31 : }
32 : }
33 : }
34 :
35 : class ContactInfoState extends ChangeNotifier {
36 : final String profileOnion;
37 : final int identifier;
38 : final String onion;
39 : late String _nickname;
40 : late String _localNickname;
41 :
42 : late ConversationNotificationPolicy _notificationPolicy;
43 :
44 : late bool _accepted;
45 : late bool _blocked;
46 : late bool _managed;
47 : late String _status;
48 : late String _imagePath;
49 : late String _defaultImagePath;
50 : late String _savePeerHistory;
51 : late int _unreadMessages = 0;
52 : late int _totalMessages = 0;
53 : bool _everFetched = false;
54 : late DateTime _lastMessageReceivedTime; // last time we received a message, for sorting
55 : late DateTime _lastMessageSentTime; // last time a message reported being sent, for display
56 : late Map<String, GlobalKey<MessageRowState>> keys;
57 : int _newMarkerMsgIndex = -1;
58 : late MessageCache messageCache;
59 : ItemScrollController messageScrollController = new ItemScrollController();
60 :
61 : // todo: a nicer way to model contacts, groups and other "entities"
62 : late bool _isGroup;
63 : bool _isShadowed = false;
64 : String? _server;
65 : late bool _archived;
66 : late bool _pinned;
67 :
68 : late String _modeLine;
69 : late String _modeMask;
70 : late List<GroupMember> _members;
71 : bool _membersDrawerOpen = false;
72 : bool _groupOpPending = false;
73 : Timer? slowModeTimer = null;
74 :
75 : int _antispamTickets = 0;
76 : String? _acnCircuit;
77 : MessageDraft _messageDraft = MessageDraft.empty();
78 :
79 : var _hoveredIndex = -1;
80 : var _pendingScroll = -1;
81 :
82 : DateTime _lastRetryTime = DateTime.now();
83 : DateTime loaded = DateTime.now();
84 :
85 : List<ContactEvent> contactEvents = List.empty(growable: true);
86 :
87 0 : ContactInfoState(
88 : this.profileOnion,
89 : this.identifier,
90 : this.onion, {
91 : nickname = "",
92 : localNickname = "",
93 : isGroup = false,
94 : accepted = false,
95 : blocked = false,
96 : status = "",
97 : imagePath = "",
98 : defaultImagePath = "",
99 : savePeerHistory = "DeleteHistoryConfirmed",
100 : numMessages = 0,
101 : numUnread = 0,
102 : lastMessageTime,
103 : server,
104 : archived = false,
105 : notificationPolicy = "ConversationNotificationPolicy.Default",
106 : pinned = false,
107 : isManaged = false,
108 : }) {
109 0 : this._nickname = nickname;
110 0 : this._localNickname = localNickname;
111 0 : this._isGroup = isGroup;
112 0 : this._accepted = accepted;
113 0 : this._blocked = blocked;
114 0 : this._status = status;
115 0 : this._imagePath = imagePath;
116 0 : this._defaultImagePath = defaultImagePath;
117 0 : this._totalMessages = numMessages;
118 0 : this._unreadMessages = numUnread;
119 0 : this._savePeerHistory = savePeerHistory;
120 0 : this._lastMessageReceivedTime = lastMessageTime == null ? DateTime.fromMillisecondsSinceEpoch(0) : lastMessageTime;
121 0 : this._lastMessageSentTime = _lastMessageReceivedTime;
122 0 : this._server = server;
123 0 : this._archived = archived;
124 0 : this._notificationPolicy = notificationPolicyFromString(notificationPolicy);
125 0 : this.messageCache = new MessageCache(_totalMessages);
126 0 : this._pinned = pinned;
127 0 : this._managed = isManaged;
128 0 : this._modeLine = "unset";
129 0 : this._modeMask = "unset";
130 0 : keys = Map<String, GlobalKey<MessageRowState>>();
131 0 : this._members = List<GroupMember>.empty(growable: true);
132 : }
133 :
134 0 : String get nickname {
135 0 : if (this._localNickname != "") {
136 0 : return this._localNickname;
137 : }
138 0 : return this._nickname;
139 : }
140 :
141 : // hybrid groups need to access the remote groupbot's "authoritative" name setting so mods can edit it
142 0 : String get remoteNickname {
143 0 : return this._nickname;
144 : }
145 :
146 0 : String get savePeerHistory => this._savePeerHistory;
147 :
148 0 : String? get acnCircuit => this._acnCircuit;
149 :
150 0 : MessageDraft get messageDraft => this._messageDraft;
151 :
152 0 : DateTime get lastRetryTime => this._lastRetryTime;
153 0 : set lastRetryTime(DateTime lastRetryTime) {
154 0 : this._lastRetryTime = lastRetryTime;
155 0 : notifyListeners();
156 : }
157 :
158 0 : set antispamTickets(int antispamTickets) {
159 0 : this._antispamTickets = antispamTickets;
160 0 : notifyListeners();
161 : }
162 :
163 0 : int get antispamTickets => this._antispamTickets;
164 :
165 0 : set acnCircuit(String? acnCircuit) {
166 0 : this._acnCircuit = acnCircuit;
167 0 : notifyListeners();
168 : }
169 :
170 : // Indicated whether the conversation is archived, in which case it will
171 : // be moved to the very bottom of the active conversations list until
172 : // new messages appear
173 0 : set isArchived(bool archived) {
174 0 : this._archived = archived;
175 0 : notifyListeners();
176 : }
177 :
178 0 : bool get isArchived => this._archived;
179 :
180 0 : set savePeerHistory(String newVal) {
181 0 : this._savePeerHistory = newVal;
182 0 : notifyListeners();
183 : }
184 :
185 0 : set nickname(String newVal) {
186 0 : this._nickname = newVal;
187 0 : notifyListeners();
188 : }
189 :
190 0 : set localNickname(String newVal) {
191 0 : this._localNickname = newVal;
192 0 : notifyListeners();
193 : }
194 :
195 0 : bool get isGroup => this._isGroup;
196 :
197 0 : set isGroup(bool newVal) {
198 0 : this._isGroup = newVal;
199 0 : notifyListeners();
200 : }
201 :
202 0 : bool get isShadowed => this._isShadowed;
203 0 : set isShadowed(bool newVal) {
204 0 : this._isShadowed = newVal;
205 0 : notifyListeners();
206 : }
207 :
208 0 : bool get membersDrawerOpen => this._membersDrawerOpen;
209 0 : set membersDrawerOpen(bool newVal) {
210 0 : this._membersDrawerOpen = newVal;
211 0 : notifyListeners();
212 : }
213 :
214 0 : bool checkOpPending(BuildContext context) {
215 : // hack to disable group operation buttons when experiment is disabled
216 0 : return this._groupOpPending || !Provider.of<Settings>(context, listen: false).isExperimentEnabled(GroupManagerExperiment);
217 : }
218 :
219 0 : set isOpPending(bool newVal) {
220 0 : this._groupOpPending = newVal;
221 0 : notifyListeners();
222 : }
223 :
224 0 : bool get isBlocked => this._blocked;
225 :
226 0 : bool get isInvitation => !this._blocked && !this._accepted;
227 :
228 0 : set accepted(bool newVal) {
229 0 : this._accepted = newVal;
230 0 : notifyListeners();
231 : }
232 :
233 0 : set blocked(bool newVal) {
234 0 : this._blocked = newVal;
235 0 : notifyListeners();
236 : }
237 :
238 0 : String get status => this._status;
239 :
240 0 : set status(String newVal) {
241 0 : this._status = newVal;
242 0 : this.contactEvents.add(ContactEvent("Update Peer Status Received: $newVal"));
243 0 : notifyListeners();
244 : }
245 :
246 0 : set messageDraft(MessageDraft newVal) {
247 0 : this._messageDraft = newVal;
248 0 : notifyListeners();
249 : }
250 :
251 0 : void notifyMessageDraftUpdate() {
252 0 : notifyListeners();
253 : }
254 :
255 0 : void selected() {
256 0 : this._newMarkerMsgIndex = this._unreadMessages - 1;
257 0 : this._unreadMessages = 0;
258 : }
259 :
260 0 : void unselected() {
261 0 : this._newMarkerMsgIndex = -1;
262 : }
263 :
264 0 : int get unreadMessages => this._unreadMessages;
265 :
266 0 : set unreadMessages(int newVal) {
267 0 : this._unreadMessages = newVal;
268 0 : notifyListeners();
269 : }
270 :
271 0 : int get newMarkerMsgIndex {
272 0 : return this._newMarkerMsgIndex;
273 : }
274 :
275 0 : int get totalMessages => this._totalMessages;
276 :
277 0 : set totalMessages(int newVal) {
278 0 : this._totalMessages = newVal;
279 0 : this.messageCache.storageMessageCount = newVal;
280 0 : notifyListeners();
281 : }
282 :
283 0 : int get uiLoadedMessages => this.messageCache.uiLoadedMessages;
284 :
285 0 : bool get everFetched => this._everFetched;
286 :
287 0 : set everFetched(bool newVal) {
288 0 : this._everFetched = newVal;
289 : }
290 :
291 0 : String get imagePath {
292 : // don't show custom images for blocked contacts..
293 0 : if (!this.isBlocked) {
294 0 : return this._imagePath;
295 : }
296 0 : return this.defaultImagePath;
297 : }
298 :
299 0 : set imagePath(String newVal) {
300 0 : this._imagePath = newVal;
301 0 : notifyListeners();
302 : }
303 :
304 0 : String get defaultImagePath => this._defaultImagePath;
305 :
306 0 : set defaultImagePath(String newVal) {
307 0 : this._defaultImagePath = newVal;
308 0 : notifyListeners();
309 : }
310 :
311 : // This is last message received time (local) and to be used for sorting only
312 : // for instance, group sync, we want to pop to the top, so we set to time.Now() for new messages
313 : // but it should not be used for display
314 0 : DateTime get lastMessageReceivedTime => this._lastMessageReceivedTime;
315 :
316 0 : set lastMessageReceivedTime(DateTime newVal) {
317 0 : this._lastMessageReceivedTime = newVal;
318 0 : notifyListeners();
319 : }
320 :
321 : // This is last message sent time and is based on message reports of sent times
322 : // this can be used to display in the contact list a last time a message was received
323 0 : DateTime get lastMessageSentTime => this._lastMessageSentTime;
324 0 : set lastMessageSentTime(DateTime newVal) {
325 0 : this._lastMessageSentTime = newVal;
326 0 : notifyListeners();
327 : }
328 :
329 : // we only allow callers to fetch the server
330 0 : String? get server => this._server;
331 :
332 0 : bool get isManaged => this._managed;
333 :
334 0 : bool isOnline() {
335 0 : if (this.isGroup == true) {
336 : // We now have an out of sync warning so we will mark these as online...
337 0 : return this.status == "Authenticated" || this.status == "Synced";
338 : } else {
339 0 : return this.status == "Authenticated";
340 : }
341 : }
342 :
343 0 : bool canSend() {
344 0 : if (this.isGroup == true) {
345 : // We now have an out of sync warning so we will mark these as online...
346 0 : return this.status == "Synced" && this.antispamTickets > 0;
347 : } else {
348 0 : return this.isOnline();
349 : }
350 : }
351 :
352 0 : ConversationNotificationPolicy get notificationsPolicy => _notificationPolicy;
353 :
354 0 : set notificationsPolicy(ConversationNotificationPolicy newVal) {
355 0 : _notificationPolicy = newVal;
356 0 : notifyListeners();
357 : }
358 :
359 0 : GlobalKey<MessageRowState> getMessageKey(int conversation, int message) {
360 0 : String index = "c: " + conversation.toString() + " m:" + message.toString();
361 0 : if (keys[index] == null) {
362 0 : keys[index] = GlobalKey<MessageRowState>();
363 : }
364 0 : GlobalKey<MessageRowState> ret = keys[index]!;
365 : return ret;
366 : }
367 :
368 0 : GlobalKey<MessageRowState>? getMessageKeyOrFail(int conversation, int message) {
369 0 : String index = "c: " + conversation.toString() + " m:" + message.toString();
370 :
371 0 : if (keys[index] == null) {
372 : return null;
373 : }
374 0 : GlobalKey<MessageRowState> ret = keys[index]!;
375 : return ret;
376 : }
377 :
378 0 : void newMessage(
379 : int identifier,
380 : int messageID,
381 : DateTime timestamp,
382 : String senderHandle,
383 : String senderImage,
384 : bool isAuto,
385 : String data,
386 : String contenthash,
387 : bool selectedConversation,
388 : String signature,
389 : ) {
390 : if (!selectedConversation) {
391 0 : unreadMessages++;
392 : }
393 0 : if (_newMarkerMsgIndex == -1) {
394 : if (!selectedConversation) {
395 0 : _newMarkerMsgIndex = 0;
396 : }
397 : } else {
398 0 : _newMarkerMsgIndex++;
399 : }
400 :
401 0 : this._lastMessageReceivedTime = timestamp;
402 0 : this._lastMessageSentTime = timestamp;
403 0 : this.messageCache.addNew(profileOnion, identifier, messageID, timestamp, senderHandle, senderImage, isAuto, data, contenthash, signature);
404 0 : this.totalMessages += 1;
405 :
406 : // FIXME: the UI does not receive events to unshadow some contacts (e.g. when sharing a group, even though they are unshadowed in
407 : // the backend. For now, just assume that if we see a message from a shadowed peer, then they should be visible (this is currently always correct)
408 0 : this.isShadowed = false;
409 :
410 : // We only ever see messages from authenticated peers.
411 : // If the contact is marked as offline then override this - can happen when the contact is removed from the front
412 : // end during syncing.
413 0 : if (isOnline() == false) {
414 0 : status = "Authenticated";
415 : }
416 0 : notifyListeners();
417 : }
418 :
419 0 : void ackCache(int messageID) {
420 0 : this.messageCache.ackCache(messageID);
421 0 : notifyListeners();
422 : }
423 :
424 0 : void errCache(int messageID) {
425 0 : this.messageCache.errCache(messageID);
426 0 : notifyListeners();
427 : }
428 :
429 0 : void updateMessage(int messageID, dynamic message, String? signature, dynamic attributes) {
430 0 : this.messageCache.update(messageID, message, signature, attributes);
431 0 : notifyListeners();
432 : }
433 :
434 0 : static ConversationNotificationPolicy notificationPolicyFromString(String val) {
435 : switch (val) {
436 0 : case "ConversationNotificationPolicy.Default":
437 : return ConversationNotificationPolicy.Default;
438 0 : case "ConversationNotificationPolicy.OptIn":
439 : return ConversationNotificationPolicy.OptIn;
440 0 : case "ConversationNotificationPolicy.Never":
441 : return ConversationNotificationPolicy.Never;
442 : }
443 : return ConversationNotificationPolicy.Never;
444 : }
445 :
446 0 : bool get pinned {
447 0 : return _pinned;
448 : }
449 :
450 : // Pin the conversation to the top of the conversation list
451 : // Requires caller tree to contain a FlwtchState and ProfileInfoState provider.
452 0 : void pin(context) {
453 0 : _pinned = true;
454 0 : var profileHandle = Provider.of<ProfileInfoState>(context, listen: false).onion;
455 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetConversationAttribute(profileHandle, identifier, "profile.pinned", "true");
456 0 : notifyListeners();
457 : }
458 :
459 : // Unpin the conversation from the top of the conversation list
460 : // Requires caller tree to contain a FlwtchState and ProfileInfoState provider.
461 0 : void unpin(context) {
462 0 : _pinned = false;
463 0 : var profileHandle = Provider.of<ProfileInfoState>(context, listen: false).onion;
464 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetConversationAttribute(profileHandle, identifier, "profile.pinned", "false");
465 0 : notifyListeners();
466 : }
467 :
468 : // returns true only if the conversation has been accepted, and has not been blocked
469 0 : bool isAccepted() {
470 0 : return _accepted && !_blocked;
471 : }
472 :
473 : String summary = "";
474 0 : void updateSummaryEvent(String summary) {
475 0 : this.summary += summary;
476 0 : notifyListeners();
477 : }
478 :
479 0 : void updateTranslationEvent(int messageID, String translation) {
480 0 : this.messageCache.updateTranslationEvent(messageID, translation);
481 0 : notifyListeners();
482 : }
483 :
484 : // Contact Attributes. Can be set in Profile Edit View...
485 : List<String?> attributes = [null, null, null];
486 0 : void setAttribute(int i, String? value) {
487 0 : this.attributes[i] = value;
488 0 : notifyListeners();
489 : }
490 :
491 : ProfileStatusMenu availabilityStatus = ProfileStatusMenu.available;
492 0 : void setAvailabilityStatus(String status) {
493 : switch (status) {
494 0 : case "available":
495 0 : availabilityStatus = ProfileStatusMenu.available;
496 : break;
497 0 : case "busy":
498 0 : availabilityStatus = ProfileStatusMenu.busy;
499 : break;
500 0 : case "away":
501 0 : availabilityStatus = ProfileStatusMenu.away;
502 : break;
503 : default:
504 : ProfileStatusMenu.available;
505 : }
506 0 : notifyListeners();
507 : }
508 :
509 0 : Color getBorderColor(OpaqueThemeType theme) {
510 0 : if (this.isBlocked) {
511 0 : return theme.portraitBlockedBorderColor;
512 : }
513 0 : if (this.isOnline()) {
514 0 : switch (this.availabilityStatus) {
515 0 : case ProfileStatusMenu.available:
516 0 : return theme.portraitOnlineBorderColor;
517 0 : case ProfileStatusMenu.away:
518 0 : return theme.portraitOnlineAwayColor;
519 0 : case ProfileStatusMenu.busy:
520 0 : return theme.portraitOnlineBusyColor;
521 : default:
522 : // noop not a valid status...
523 : break;
524 : }
525 : }
526 0 : return theme.portraitOfflineBorderColor;
527 : }
528 :
529 0 : String augmentedNickname(BuildContext context) {
530 0 : var nick = redactedNick(context, this.onion, this.nickname);
531 0 : return nick + (this.availabilityStatus == ProfileStatusMenu.available ? "" : " (" + this.statusString(context) + ")");
532 : }
533 :
534 : // Never use this for message lookup - can be a non-indexed value
535 : // e.g. -1
536 0 : int get hoveredIndex => _hoveredIndex;
537 0 : set hoveredIndex(int newVal) {
538 0 : this._hoveredIndex = newVal;
539 0 : notifyListeners();
540 : }
541 :
542 0 : int get pendingScroll => _pendingScroll;
543 0 : set pendingScroll(int newVal) {
544 0 : this._pendingScroll = newVal;
545 0 : notifyListeners();
546 : }
547 :
548 0 : String statusString(BuildContext context) {
549 0 : switch (this.availabilityStatus) {
550 0 : case ProfileStatusMenu.available:
551 0 : return AppLocalizations.of(context)!.availabilityStatusAvailable;
552 0 : case ProfileStatusMenu.away:
553 0 : return AppLocalizations.of(context)!.availabilityStatusAway;
554 0 : case ProfileStatusMenu.busy:
555 0 : return AppLocalizations.of(context)!.availabilityStatusBusy;
556 : default:
557 0 : throw UnimplementedError("not a valid status");
558 : }
559 : }
560 :
561 0 : List<GroupMember> get members => _members;
562 0 : set members(List<GroupMember> newVal) {
563 0 : this._members = newVal;
564 0 : notifyListeners();
565 : }
566 :
567 0 : List<GroupMember> getActiveMembers() {
568 0 : return this._members.where((x) => x.role != "blocked").toList();
569 : }
570 :
571 0 : List<GroupMember> getMembersOfRole(String role) {
572 0 : return this._members.where((x) => x.role == role).toList();
573 : }
574 :
575 0 : String getRole(String onion) {
576 : try {
577 0 : return this._members.firstWhere((x) => x.onion == onion).role;
578 : } catch (e) {
579 : //member not found
580 : return '';
581 : }
582 : }
583 :
584 0 : String get modeLine => _modeLine;
585 0 : set modeLine(String newVal) {
586 0 : this._modeLine = newVal;
587 0 : notifyListeners();
588 : }
589 :
590 0 : String get modeMask => _modeMask;
591 0 : set modeMask(String newVal) {
592 0 : this._modeMask = newVal;
593 0 : notifyListeners();
594 : }
595 : }
596 :
597 : class ContactEvent {
598 : String summary;
599 : late DateTime timestamp;
600 0 : ContactEvent(this.summary) {
601 0 : this.timestamp = DateTime.now();
602 : }
603 : }
|