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