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 : // We only ever see messages from authenticated peers.
407 : // If the contact is marked as offline then override this - can happen when the contact is removed from the front
408 : // end during syncing.
409 0 : if (isOnline() == false) {
410 0 : status = "Authenticated";
411 : }
412 0 : notifyListeners();
413 : }
414 :
415 0 : void ackCache(int messageID) {
416 0 : this.messageCache.ackCache(messageID);
417 0 : notifyListeners();
418 : }
419 :
420 0 : void errCache(int messageID) {
421 0 : this.messageCache.errCache(messageID);
422 0 : notifyListeners();
423 : }
424 :
425 0 : void updateMessage(int messageID, dynamic message, String? signature, dynamic attributes) {
426 0 : this.messageCache.update(messageID, message, signature, attributes);
427 0 : notifyListeners();
428 : }
429 :
430 0 : static ConversationNotificationPolicy notificationPolicyFromString(String val) {
431 : switch (val) {
432 0 : case "ConversationNotificationPolicy.Default":
433 : return ConversationNotificationPolicy.Default;
434 0 : case "ConversationNotificationPolicy.OptIn":
435 : return ConversationNotificationPolicy.OptIn;
436 0 : case "ConversationNotificationPolicy.Never":
437 : return ConversationNotificationPolicy.Never;
438 : }
439 : return ConversationNotificationPolicy.Never;
440 : }
441 :
442 0 : bool get pinned {
443 0 : return _pinned;
444 : }
445 :
446 : // Pin the conversation to the top of the conversation list
447 : // Requires caller tree to contain a FlwtchState and ProfileInfoState provider.
448 0 : void pin(context) {
449 0 : _pinned = true;
450 0 : var profileHandle = Provider.of<ProfileInfoState>(context, listen: false).onion;
451 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetConversationAttribute(profileHandle, identifier, "profile.pinned", "true");
452 0 : notifyListeners();
453 : }
454 :
455 : // Unpin the conversation from the top of the conversation list
456 : // Requires caller tree to contain a FlwtchState and ProfileInfoState provider.
457 0 : void unpin(context) {
458 0 : _pinned = false;
459 0 : var profileHandle = Provider.of<ProfileInfoState>(context, listen: false).onion;
460 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetConversationAttribute(profileHandle, identifier, "profile.pinned", "false");
461 0 : notifyListeners();
462 : }
463 :
464 : // returns true only if the conversation has been accepted, and has not been blocked
465 0 : bool isAccepted() {
466 0 : return _accepted && !_blocked;
467 : }
468 :
469 : String summary = "";
470 0 : void updateSummaryEvent(String summary) {
471 0 : this.summary += summary;
472 0 : notifyListeners();
473 : }
474 :
475 0 : void updateTranslationEvent(int messageID, String translation) {
476 0 : this.messageCache.updateTranslationEvent(messageID, translation);
477 0 : notifyListeners();
478 : }
479 :
480 : // Contact Attributes. Can be set in Profile Edit View...
481 : List<String?> attributes = [null, null, null];
482 0 : void setAttribute(int i, String? value) {
483 0 : this.attributes[i] = value;
484 0 : notifyListeners();
485 : }
486 :
487 : ProfileStatusMenu availabilityStatus = ProfileStatusMenu.available;
488 0 : void setAvailabilityStatus(String status) {
489 : switch (status) {
490 0 : case "available":
491 0 : availabilityStatus = ProfileStatusMenu.available;
492 : break;
493 0 : case "busy":
494 0 : availabilityStatus = ProfileStatusMenu.busy;
495 : break;
496 0 : case "away":
497 0 : availabilityStatus = ProfileStatusMenu.away;
498 : break;
499 : default:
500 : ProfileStatusMenu.available;
501 : }
502 0 : notifyListeners();
503 : }
504 :
505 0 : Color getBorderColor(OpaqueThemeType theme) {
506 0 : if (this.isBlocked) {
507 0 : return theme.portraitBlockedBorderColor;
508 : }
509 0 : if (this.isOnline()) {
510 0 : switch (this.availabilityStatus) {
511 0 : case ProfileStatusMenu.available:
512 0 : return theme.portraitOnlineBorderColor;
513 0 : case ProfileStatusMenu.away:
514 0 : return theme.portraitOnlineAwayColor;
515 0 : case ProfileStatusMenu.busy:
516 0 : return theme.portraitOnlineBusyColor;
517 : default:
518 : // noop not a valid status...
519 : break;
520 : }
521 : }
522 0 : return theme.portraitOfflineBorderColor;
523 : }
524 :
525 0 : String augmentedNickname(BuildContext context) {
526 0 : var nick = redactedNick(context, this.onion, this.nickname);
527 0 : return nick + (this.availabilityStatus == ProfileStatusMenu.available ? "" : " (" + this.statusString(context) + ")");
528 : }
529 :
530 : // Never use this for message lookup - can be a non-indexed value
531 : // e.g. -1
532 0 : int get hoveredIndex => _hoveredIndex;
533 0 : set hoveredIndex(int newVal) {
534 0 : this._hoveredIndex = newVal;
535 0 : notifyListeners();
536 : }
537 :
538 0 : int get pendingScroll => _pendingScroll;
539 0 : set pendingScroll(int newVal) {
540 0 : this._pendingScroll = newVal;
541 0 : notifyListeners();
542 : }
543 :
544 0 : String statusString(BuildContext context) {
545 0 : switch (this.availabilityStatus) {
546 0 : case ProfileStatusMenu.available:
547 0 : return AppLocalizations.of(context)!.availabilityStatusAvailable;
548 0 : case ProfileStatusMenu.away:
549 0 : return AppLocalizations.of(context)!.availabilityStatusAway;
550 0 : case ProfileStatusMenu.busy:
551 0 : return AppLocalizations.of(context)!.availabilityStatusBusy;
552 : default:
553 0 : throw UnimplementedError("not a valid status");
554 : }
555 : }
556 :
557 0 : List<GroupMember> get members => _members;
558 0 : set members(List<GroupMember> newVal) {
559 0 : this._members = newVal;
560 0 : notifyListeners();
561 : }
562 :
563 0 : List<GroupMember> getMembersOfRole(String role) {
564 0 : return this._members.where((x) => x.role == role).toList();
565 : }
566 :
567 0 : String getRole(String onion) {
568 : try {
569 0 : return this._members.firstWhere((x) => x.onion == onion).role;
570 : } catch (e) {
571 : //member not found
572 : return '';
573 : }
574 : }
575 :
576 0 : String get modeLine => _modeLine;
577 0 : set modeLine(String newVal) {
578 0 : this._modeLine = newVal;
579 0 : notifyListeners();
580 : }
581 :
582 0 : String get modeMask => _modeMask;
583 0 : set modeMask(String newVal) {
584 0 : this._modeMask = newVal;
585 0 : notifyListeners();
586 : }
587 : }
588 :
589 : class ContactEvent {
590 : String summary;
591 : late DateTime timestamp;
592 0 : ContactEvent(this.summary) {
593 0 : this.timestamp = DateTime.now();
594 : }
595 : }
|