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