That fuck shit the fascists are using
1/*
2 * Copyright (C) 2011 Whisper Systems
3 *
4 * This program is free software: you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation, either version 3 of the License, or
7 * (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <http://www.gnu.org/licenses/>.
16 */
17package org.tm.archive.conversation;
18
19import android.annotation.SuppressLint;
20import android.content.Context;
21import android.os.Build;
22import android.view.LayoutInflater;
23import android.view.View;
24import android.view.ViewGroup;
25import android.widget.FrameLayout;
26import android.widget.TextView;
27
28import androidx.annotation.ColorInt;
29import androidx.annotation.DrawableRes;
30import androidx.annotation.LayoutRes;
31import androidx.annotation.MainThread;
32import androidx.annotation.NonNull;
33import androidx.annotation.Nullable;
34import androidx.core.content.ContextCompat;
35import androidx.lifecycle.LifecycleOwner;
36import androidx.recyclerview.widget.DiffUtil;
37import androidx.recyclerview.widget.ListAdapter;
38import androidx.recyclerview.widget.RecyclerView;
39
40import androidx.media3.common.MediaItem;
41
42import com.bumptech.glide.RequestManager;
43
44import org.signal.core.util.logging.Log;
45import org.signal.paging.PagingController;
46import org.tm.archive.BindableConversationItem;
47import org.tm.archive.R;
48import org.tm.archive.conversation.colors.Colorizable;
49import org.tm.archive.conversation.colors.Colorizer;
50import org.tm.archive.conversation.mutiselect.MultiselectPart;
51import org.tm.archive.database.model.MmsMessageRecord;
52import org.tm.archive.database.model.MessageRecord;
53import org.tm.archive.giph.mp4.GiphyMp4Playable;
54import org.tm.archive.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
55import org.tm.archive.recipients.RecipientId;
56import org.tm.archive.util.CachedInflater;
57import org.tm.archive.util.DateUtils;
58import org.tm.archive.util.Projection;
59import org.tm.archive.util.ProjectionList;
60import org.tm.archive.util.StickyHeaderDecoration;
61import org.tm.archive.util.ThemeUtil;
62import org.tm.archive.util.ViewUtil;
63
64import java.util.Calendar;
65import java.util.HashSet;
66import java.util.List;
67import java.util.Locale;
68import java.util.Objects;
69import java.util.Optional;
70import java.util.Set;
71
72/**
73 * Adapter that renders a conversation.
74 *
75 * Important spacial thing to keep in mind: The adapter is intended to be shown on a reversed layout
76 * manager, so position 0 is at the bottom of the screen. That's why the "header" is at the bottom,
77 * the "footer" is at the top, and we refer to the "next" record as having a lower index.
78 */
79public class ConversationAdapter
80 extends ListAdapter<ConversationMessage, RecyclerView.ViewHolder>
81 implements StickyHeaderDecoration.StickyHeaderAdapter<ConversationAdapter.StickyHeaderViewHolder>,
82 ConversationAdapterBridge
83{
84
85 private static final String TAG = Log.tag(ConversationAdapter.class);
86
87 public static final int HEADER_TYPE_POPOVER_DATE = 1;
88 public static final int HEADER_TYPE_INLINE_DATE = 2;
89 public static final int HEADER_TYPE_LAST_SEEN = 3;
90
91 private static final int MESSAGE_TYPE_OUTGOING_MULTIMEDIA = 0;
92 private static final int MESSAGE_TYPE_OUTGOING_TEXT = 1;
93 private static final int MESSAGE_TYPE_INCOMING_MULTIMEDIA = 2;
94 private static final int MESSAGE_TYPE_INCOMING_TEXT = 3;
95 private static final int MESSAGE_TYPE_UPDATE = 4;
96 private static final int MESSAGE_TYPE_HEADER = 5;
97 public static final int MESSAGE_TYPE_FOOTER = 6;
98 private static final int MESSAGE_TYPE_PLACEHOLDER = 7;
99
100 private final ItemClickListener clickListener;
101 private final Context context;
102 private final LifecycleOwner lifecycleOwner;
103 private final RequestManager requestManager;
104 private final Locale locale;
105 private final Set<MultiselectPart> selected;
106 private final Calendar calendar;
107
108 private String searchQuery;
109 private ConversationMessage recordToPulse;
110 private View typingView;
111 private View footerView;
112 private PagingController pagingController;
113 private boolean hasWallpaper;
114 private boolean isMessageRequestAccepted;
115 private ConversationMessage inlineContent;
116 private Colorizer colorizer;
117 private boolean isTypingViewEnabled;
118 private ConversationItemDisplayMode displayMode;
119 private PulseRequest pulseRequest;
120
121 public ConversationAdapter(@NonNull Context context,
122 @NonNull LifecycleOwner lifecycleOwner,
123 @NonNull RequestManager requestManager,
124 @NonNull Locale locale,
125 @Nullable ItemClickListener clickListener,
126 boolean hasWallpaper,
127 @NonNull Colorizer colorizer)
128 {
129 super(new DiffUtil.ItemCallback<ConversationMessage>() {
130 @Override
131 public boolean areItemsTheSame(@NonNull ConversationMessage oldItem, @NonNull ConversationMessage newItem) {
132 return oldItem.getMessageRecord().getId() == newItem.getMessageRecord().getId();
133 }
134
135 @Override
136 public boolean areContentsTheSame(@NonNull ConversationMessage oldItem, @NonNull ConversationMessage newItem) {
137 return false;
138 }
139 });
140
141 this.lifecycleOwner = lifecycleOwner;
142 this.context = context;
143
144 this.requestManager = requestManager;
145 this.locale = locale;
146 this.clickListener = clickListener;
147 this.selected = new HashSet<>();
148 this.calendar = Calendar.getInstance();
149 this.hasWallpaper = hasWallpaper;
150 this.isMessageRequestAccepted = true;
151 this.colorizer = colorizer;
152 }
153
154 @Override
155 public int getItemViewType(int position) {
156 if (isTypingViewEnabled() && position == 0) {
157 return MESSAGE_TYPE_HEADER;
158 }
159
160 if (hasFooter() && position == getItemCount() - 1) {
161 return MESSAGE_TYPE_FOOTER;
162 }
163
164 ConversationMessage conversationMessage = getItem(position);
165 MessageRecord messageRecord = (conversationMessage != null) ? conversationMessage.getMessageRecord() : null;
166
167 if (messageRecord == null) {
168 return MESSAGE_TYPE_PLACEHOLDER;
169 } else if (messageRecord.isUpdate()) {
170 return MESSAGE_TYPE_UPDATE;
171 } else if (messageRecord.isOutgoing()) {
172 return conversationMessage.isTextOnly(context) ? MESSAGE_TYPE_OUTGOING_TEXT : MESSAGE_TYPE_OUTGOING_MULTIMEDIA;
173 } else {
174 return conversationMessage.isTextOnly(context) ? MESSAGE_TYPE_INCOMING_TEXT : MESSAGE_TYPE_INCOMING_MULTIMEDIA;
175 }
176 }
177
178 @SuppressLint("ClickableViewAccessibility")
179 @Override
180 public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
181 switch (viewType) {
182 case MESSAGE_TYPE_INCOMING_TEXT:
183 case MESSAGE_TYPE_INCOMING_MULTIMEDIA:
184 case MESSAGE_TYPE_OUTGOING_TEXT:
185 case MESSAGE_TYPE_OUTGOING_MULTIMEDIA:
186 case MESSAGE_TYPE_UPDATE:
187 View itemView = CachedInflater.from(parent.getContext()).inflate(getLayoutForViewType(viewType), parent, false);
188 BindableConversationItem bindable = (BindableConversationItem) itemView;
189
190 itemView.setOnClickListener((v) -> {
191 if (clickListener != null) {
192 clickListener.onItemClick(bindable.getMultiselectPartForLatestTouch());
193 }
194 });
195
196 itemView.setOnLongClickListener((v) -> {
197 if (clickListener != null) {
198 clickListener.onItemLongClick(itemView, bindable.getMultiselectPartForLatestTouch());
199 }
200
201 return true;
202 });
203
204 bindable.setEventListener(clickListener);
205
206 return new ConversationViewHolder(itemView);
207 case MESSAGE_TYPE_PLACEHOLDER:
208 View v = new FrameLayout(parent.getContext());
209 v.setLayoutParams(new FrameLayout.LayoutParams(1, ViewUtil.dpToPx(100)));
210 return new PlaceholderViewHolder(v);
211 case MESSAGE_TYPE_HEADER:
212 return new HeaderViewHolder(CachedInflater.from(parent.getContext()).inflate(R.layout.cursor_adapter_header_footer_view, parent, false));
213 case MESSAGE_TYPE_FOOTER:
214 return new FooterViewHolder(CachedInflater.from(parent.getContext()).inflate(R.layout.cursor_adapter_header_footer_view, parent, false));
215 default:
216 throw new IllegalStateException("Cannot create viewholder for type: " + viewType);
217 }
218 }
219
220 private boolean containsValidPayload(@NonNull List<Object> payloads) {
221 return payloads.contains(PAYLOAD_TIMESTAMP) || payloads.contains(PAYLOAD_NAME_COLORS) || payloads.contains(PAYLOAD_SELECTED);
222 }
223
224 @Override
225 public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position, @NonNull List<Object> payloads) {
226 if (containsValidPayload(payloads)) {
227 switch (getItemViewType(position)) {
228 case MESSAGE_TYPE_INCOMING_TEXT:
229 case MESSAGE_TYPE_INCOMING_MULTIMEDIA:
230 case MESSAGE_TYPE_OUTGOING_TEXT:
231 case MESSAGE_TYPE_OUTGOING_MULTIMEDIA:
232 case MESSAGE_TYPE_UPDATE:
233 ConversationViewHolder conversationViewHolder = (ConversationViewHolder) holder;
234 if (payloads.contains(PAYLOAD_TIMESTAMP)) {
235 conversationViewHolder.getBindable().updateTimestamps();
236 }
237
238 if (payloads.contains(PAYLOAD_NAME_COLORS)) {
239 conversationViewHolder.getBindable().updateContactNameColor();
240 }
241
242 if (payloads.contains(PAYLOAD_SELECTED)) {
243 conversationViewHolder.getBindable().updateSelectedState();
244 }
245
246 default:
247 return;
248 }
249 } else {
250 super.onBindViewHolder(holder, position, payloads);
251 }
252 }
253
254 public void setCondensedMode(ConversationItemDisplayMode condensedMode) {
255 this.displayMode = condensedMode;
256 notifyDataSetChanged();
257 }
258
259 @Override
260 public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
261 switch (getItemViewType(position)) {
262 case MESSAGE_TYPE_INCOMING_TEXT:
263 case MESSAGE_TYPE_INCOMING_MULTIMEDIA:
264 case MESSAGE_TYPE_OUTGOING_TEXT:
265 case MESSAGE_TYPE_OUTGOING_MULTIMEDIA:
266 case MESSAGE_TYPE_UPDATE:
267 ConversationViewHolder conversationViewHolder = (ConversationViewHolder) holder;
268 ConversationMessage conversationMessage = Objects.requireNonNull(getItem(position));
269 int adapterPosition = holder.getAdapterPosition();
270
271 ConversationMessage previousMessage = adapterPosition < getItemCount() - 1 && !isFooterPosition(adapterPosition + 1) ? getItem(adapterPosition + 1) : null;
272 ConversationMessage nextMessage = adapterPosition > 0 && !isHeaderPosition(adapterPosition - 1) ? getItem(adapterPosition - 1) : null;
273
274 ConversationItemDisplayMode itemDisplayMode = displayMode != null ? displayMode : ConversationItemDisplayMode.Standard.INSTANCE;
275
276 conversationViewHolder.getBindable().bind(lifecycleOwner,
277 conversationMessage,
278 Optional.ofNullable(previousMessage != null ? previousMessage.getMessageRecord() : null),
279 Optional.ofNullable(nextMessage != null ? nextMessage.getMessageRecord() : null),
280 requestManager,
281 locale,
282 selected,
283 conversationMessage.getThreadRecipient(),
284 searchQuery,
285 conversationMessage == recordToPulse,
286 hasWallpaper && itemDisplayMode.displayWallpaper(),
287 isMessageRequestAccepted,
288 conversationMessage == inlineContent,
289 colorizer,
290 itemDisplayMode);
291
292 if (conversationMessage == recordToPulse) {
293 recordToPulse = null;
294 }
295 break;
296 case MESSAGE_TYPE_HEADER:
297 ((HeaderViewHolder) holder).bind(typingView);
298 break;
299 case MESSAGE_TYPE_FOOTER:
300 ((HeaderFooterViewHolder) holder).bind(footerView);
301 break;
302 }
303 }
304
305 @Override
306 public int getItemCount() {
307 boolean hasFooter = footerView != null;
308 return super.getItemCount() + (isTypingViewEnabled ? 1 : 0) + (hasFooter ? 1 : 0);
309 }
310
311 @Override
312 public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
313 if (holder instanceof ConversationViewHolder) {
314 ((ConversationViewHolder) holder).getBindable().unbind();
315 }
316 }
317
318 @Override
319 public long getHeaderId(int position) {
320 if (isHeaderPosition(position)) return -1;
321 if (isFooterPosition(position)) return -1;
322 if (position >= getItemCount()) return -1;
323 if (position < 0) return -1;
324
325 ConversationMessage conversationMessage = getItem(position);
326
327 if (conversationMessage == null) return -1;
328
329 if (displayMode.getScheduleMessageMode()) {
330 calendar.setTimeInMillis(((MmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate());
331 } else if (displayMode == ConversationItemDisplayMode.EditHistory.INSTANCE) {
332 calendar.setTimeInMillis(conversationMessage.getMessageRecord().getDateSent());
333 } else {
334 calendar.setTimeInMillis(conversationMessage.getConversationTimestamp());
335 }
336 return calendar.get(Calendar.YEAR) * 1000L + calendar.get(Calendar.DAY_OF_YEAR);
337 }
338
339 @Override
340 public StickyHeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int position, int type) {
341 return new StickyHeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_item_header, parent, false));
342 }
343
344 @Override
345 public void onBindHeaderViewHolder(StickyHeaderViewHolder viewHolder, int position, int type) {
346 Context context = viewHolder.itemView.getContext();
347 ConversationMessage conversationMessage = Objects.requireNonNull(getItem(position));
348
349 if (displayMode.getScheduleMessageMode()) {
350 viewHolder.setText(DateUtils.getScheduledMessagesDateHeaderString(viewHolder.itemView.getContext(), locale, ((MmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate()));
351 } else if (displayMode == ConversationItemDisplayMode.EditHistory.INSTANCE) {
352 viewHolder.setText(DateUtils.getConversationDateHeaderString(viewHolder.itemView.getContext(), locale, conversationMessage.getMessageRecord().getDateSent()));
353 } else {
354 viewHolder.setText(DateUtils.getConversationDateHeaderString(viewHolder.itemView.getContext(), locale, conversationMessage.getConversationTimestamp()));
355 }
356
357 if (type == HEADER_TYPE_POPOVER_DATE) {
358 if (hasWallpaper) {
359 viewHolder.setBackgroundRes(R.drawable.wallpaper_bubble_background_18);
360 } else {
361 viewHolder.setBackgroundRes(R.drawable.sticky_date_header_background);
362 }
363 } else if (type == HEADER_TYPE_INLINE_DATE) {
364 if (hasWallpaper) {
365 viewHolder.setBackgroundRes(R.drawable.wallpaper_bubble_background_18);
366 } else {
367 viewHolder.clearBackground();
368 }
369 }
370
371 if (hasWallpaper && ThemeUtil.isDarkTheme(context)) {
372 viewHolder.setTextColor(ContextCompat.getColor(context, R.color.signal_colorNeutralInverse));
373 } else {
374 viewHolder.setTextColor(ContextCompat.getColor(context, R.color.signal_colorOnSurfaceVariant));
375 }
376 }
377
378 public @Nullable ConversationMessage getConversationMessage(int position) {
379 return getItem(position);
380 }
381
382 public @Nullable ConversationMessage getItem(int position) {
383 position = isTypingViewEnabled() ? position - 1 : position;
384
385 if (position < 0) {
386 return null;
387 } else {
388 if (pagingController != null) {
389 pagingController.onDataNeededAroundIndex(position);
390 }
391
392 if (position < super.getItemCount()) {
393 return super.getItem(position);
394 } else {
395 Log.d(TAG, "Could not access corrected position " + position + " as it is out of bounds.");
396 return null;
397 }
398 }
399 }
400
401 /**
402 * Checks a range around the given position for nulls.
403 *
404 * @param position The position we wish to jump to.
405 * @return true if we seem like we've paged in the right data, false if not so.
406 */
407 public boolean canJumpToPosition(int position) {
408 position = isTypingViewEnabled() ? position - 1 : position;
409 if (position < 0) {
410 return false;
411 }
412
413 if (position > super.getItemCount()) {
414 Log.d(TAG, "Could not access corrected position " + position + " as it is out of bounds.");
415 return false;
416 }
417
418 int start = Math.max(position - 10, 0);
419 int end = Math.min(position + 5, super.getItemCount());
420
421 for (int i = start; i < end; i++) {
422 if (super.getItem(i) == null) {
423 if (pagingController != null) {
424 pagingController.onDataNeededAroundIndex(position);
425 }
426
427 return false;
428 }
429 }
430
431 return true;
432 }
433
434 public void setPagingController(@Nullable PagingController pagingController) {
435 this.pagingController = pagingController;
436 }
437
438 public boolean isForRecipientId(@NonNull RecipientId recipientId) {
439 // TODO [alex] -- This should be fine, since we now have a 1:1 relationship between fragment and recipient.
440 return true;
441 }
442
443 void onBindLastSeenViewHolder(StickyHeaderViewHolder viewHolder, long unreadCount) {
444 viewHolder.setText(viewHolder.itemView.getContext().getResources().getQuantityString(R.plurals.ConversationAdapter_n_unread_messages, (int) unreadCount, (int) unreadCount));
445
446 if (hasWallpaper) {
447 viewHolder.setBackgroundRes(R.drawable.wallpaper_bubble_background_18);
448 viewHolder.setDividerColor(viewHolder.itemView.getResources().getColor(R.color.transparent_black_80));
449 } else {
450 viewHolder.clearBackground();
451 viewHolder.setDividerColor(viewHolder.itemView.getResources().getColor(R.color.core_grey_45));
452 }
453 }
454
455 public boolean hasNoConversationMessages() {
456 return super.getItemCount() == 0;
457 }
458
459 /**
460 * The presence of a header may throw off the position you'd like to jump to. This will return
461 * an adjusted message position based on adapter state.
462 */
463 @MainThread
464 public int getAdapterPositionForMessagePosition(int messagePosition) {
465 return isTypingViewEnabled() ? messagePosition + 1 : messagePosition;
466 }
467
468 /**
469 * Finds the received timestamp for the item at the requested adapter position. Will return 0 if
470 * the position doesn't refer to an incoming message.
471 */
472 @MainThread
473 long getReceivedTimestamp(int position) {
474 if (isHeaderPosition(position)) return 0;
475 if (isFooterPosition(position)) return 0;
476 if (position >= getItemCount()) return 0;
477 if (position < 0) return 0;
478
479 ConversationMessage conversationMessage = getItem(position);
480
481 if (conversationMessage == null || conversationMessage.getMessageRecord().isOutgoing()) {
482 return 0;
483 } else {
484 return conversationMessage.getMessageRecord().getDateReceived();
485 }
486 }
487
488 /**
489 * Sets the view the appears at the top of the list (because the list is reversed).
490 */
491 void setFooterView(@Nullable View view) {
492 boolean hadFooter = hasFooter();
493
494 this.footerView = view;
495
496 if (view == null && hadFooter) {
497 notifyItemRemoved(getItemCount());
498 } else if (view != null && hadFooter) {
499 notifyItemChanged(getItemCount() - 1);
500 } else if (view != null) {
501 notifyItemInserted(getItemCount() - 1);
502 }
503 }
504
505 /**
506 * Sets the view that appears at the bottom of the list (because the list is reversed).
507 */
508 void setTypingView(@NonNull View view) {
509 this.typingView = view;
510 }
511
512 void setTypingViewEnabled(boolean isTypingViewEnabled) {
513 if (typingView == null && isTypingViewEnabled) {
514 throw new IllegalStateException("Must set header before enabling.");
515 }
516
517 if (this.isTypingViewEnabled && !isTypingViewEnabled) {
518 this.isTypingViewEnabled = false;
519 notifyItemRemoved(0);
520 } else if (this.isTypingViewEnabled) {
521 notifyItemChanged(0);
522 } else if (isTypingViewEnabled) {
523 this.isTypingViewEnabled = true;
524 notifyItemInserted(0);
525 }
526 }
527
528 /**
529 * Momentarily highlights a mention at the requested position.
530 */
531 public void pulseAtPosition(int position) {
532 if (position >= 0 && position < getItemCount()) {
533 int correctedPosition = isHeaderPosition(position) ? position + 1 : position;
534
535 recordToPulse = getItem(correctedPosition);
536 pulseRequest = new PulseRequest(position, recordToPulse.getMessageRecord().isOutgoing());
537 notifyItemChanged(correctedPosition);
538 }
539 }
540
541 @Nullable
542 public PulseRequest consumePulseRequest() {
543 PulseRequest request = pulseRequest;
544 pulseRequest = null;
545 return request;
546 }
547
548 /**
549 * Conversation search query updated. Allows rendering of text highlighting.
550 */
551 void onSearchQueryUpdated(String query) {
552 if (!Objects.equals(query, this.searchQuery)) {
553 this.searchQuery = query;
554 notifyDataSetChanged();
555 }
556 }
557
558 /**
559 * Lets the adapter know that the wallpaper state has changed.
560 * @return True if the internal wallpaper state changed, otherwise false.
561 */
562 public boolean onHasWallpaperChanged(boolean hasWallpaper) {
563 if (this.hasWallpaper != hasWallpaper) {
564 Log.d(TAG, "Resetting adapter due to wallpaper change.");
565 this.hasWallpaper = hasWallpaper;
566 notifyDataSetChanged();
567 return true;
568 } else {
569 return false;
570 }
571 }
572
573 /**
574 * Returns set of records that are selected in multi-select mode.
575 */
576 public Set<MultiselectPart> getSelectedItems() {
577 return new HashSet<>(selected);
578 }
579
580 public void removeFromSelection(@NonNull Set<MultiselectPart> parts) {
581 selected.removeAll(parts);
582 updateSelected();
583 }
584
585 /**
586 * Clears all selected records from multi-select mode.
587 */
588 void clearSelection() {
589 selected.clear();
590 updateSelected();
591 }
592
593 /**
594 * Toggles the selected state of a record in multi-select mode.
595 */
596 void toggleSelection(MultiselectPart multiselectPart) {
597 if (selected.contains(multiselectPart)) {
598 selected.remove(multiselectPart);
599 } else {
600 selected.add(multiselectPart);
601 }
602 updateSelected();
603 }
604
605 private void updateSelected() {
606 notifyItemRangeChanged(0, getItemCount(), PAYLOAD_SELECTED);
607 }
608
609 /**
610 * Provided a pool, this will initialize it with view counts that make sense.
611 */
612 @MainThread
613 public static void initializePool(@NonNull RecyclerView.RecycledViewPool pool) {
614 pool.setMaxRecycledViews(MESSAGE_TYPE_INCOMING_TEXT, 25);
615 pool.setMaxRecycledViews(MESSAGE_TYPE_INCOMING_MULTIMEDIA, 15);
616 pool.setMaxRecycledViews(MESSAGE_TYPE_OUTGOING_TEXT, 25);
617 pool.setMaxRecycledViews(MESSAGE_TYPE_OUTGOING_MULTIMEDIA, 15);
618 pool.setMaxRecycledViews(MESSAGE_TYPE_PLACEHOLDER, 15);
619 pool.setMaxRecycledViews(MESSAGE_TYPE_HEADER, 1);
620 pool.setMaxRecycledViews(MESSAGE_TYPE_FOOTER, 1);
621 pool.setMaxRecycledViews(MESSAGE_TYPE_UPDATE, 5);
622 }
623
624 public boolean isTypingViewEnabled() {
625 return isTypingViewEnabled;
626 }
627
628 public boolean hasFooter() {
629 return footerView != null;
630 }
631
632 private boolean isHeaderPosition(int position) {
633 return isTypingViewEnabled() && position == 0;
634 }
635
636 private boolean isFooterPosition(int position) {
637 return hasFooter() && position == (getItemCount() - 1);
638 }
639
640 private static @LayoutRes int getLayoutForViewType(int viewType) {
641 switch (viewType) {
642 case MESSAGE_TYPE_OUTGOING_TEXT: return R.layout.conversation_item_sent_text_only;
643 case MESSAGE_TYPE_OUTGOING_MULTIMEDIA: return R.layout.conversation_item_sent_multimedia;
644 case MESSAGE_TYPE_INCOMING_TEXT: return R.layout.conversation_item_received_text_only;
645 case MESSAGE_TYPE_INCOMING_MULTIMEDIA: return R.layout.conversation_item_received_multimedia;
646 case MESSAGE_TYPE_UPDATE: return R.layout.conversation_item_update;
647 default: throw new IllegalArgumentException("Unknown type!");
648 }
649 }
650
651 public @Nullable ConversationMessage getLastVisibleConversationMessage(int position) {
652 try {
653 return getItem(position - ((hasFooter() && position == getItemCount() - 1) ? 1 : 0));
654 } catch (IndexOutOfBoundsException e) {
655 Log.w(TAG, "Race condition changed size of conversation", e);
656 return null;
657 }
658 }
659
660 public void setMessageRequestAccepted(boolean messageRequestAccepted) {
661 if (this.isMessageRequestAccepted != messageRequestAccepted) {
662 this.isMessageRequestAccepted = messageRequestAccepted;
663 notifyDataSetChanged();
664 }
665 }
666
667 public void playInlineContent(@Nullable ConversationMessage conversationMessage) {
668 if (this.inlineContent != conversationMessage) {
669 this.inlineContent = conversationMessage;
670 notifyDataSetChanged();
671 }
672 }
673
674 public void updateTimestamps() {
675 notifyItemRangeChanged(0, getItemCount(), PAYLOAD_TIMESTAMP);
676 }
677
678 final static class ConversationViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable, Colorizable {
679 public ConversationViewHolder(final @NonNull View itemView) {
680 super(itemView);
681 }
682
683 public BindableConversationItem getBindable() {
684 return (BindableConversationItem) itemView;
685 }
686
687 @Override
688 public void showProjectionArea() {
689 getBindable().showProjectionArea();
690 }
691
692 @Override
693 public void hideProjectionArea() {
694 getBindable().hideProjectionArea();
695 }
696
697 @Override
698 public @Nullable MediaItem getMediaItem() {
699 return getBindable().getMediaItem();
700 }
701
702 @Override
703 public @Nullable GiphyMp4PlaybackPolicyEnforcer getPlaybackPolicyEnforcer() {
704 return getBindable().getPlaybackPolicyEnforcer();
705 }
706
707 @Override
708 public @NonNull Projection getGiphyMp4PlayableProjection(@NonNull ViewGroup recyclerView) {
709 return getBindable().getGiphyMp4PlayableProjection(recyclerView);
710 }
711
712 @Override
713 public boolean canPlayContent() {
714 return getBindable().canPlayContent();
715 }
716
717 @Override
718 public boolean shouldProjectContent() {
719 return getBindable().shouldProjectContent();
720 }
721
722 @Override
723 public @NonNull ProjectionList getColorizerProjections(@NonNull ViewGroup coordinateRoot) {
724 return getBindable().getColorizerProjections(coordinateRoot);
725 }
726 }
727
728 static class StickyHeaderViewHolder extends RecyclerView.ViewHolder {
729 TextView textView;
730 View divider;
731
732 StickyHeaderViewHolder(View itemView) {
733 super(itemView);
734 textView = itemView.findViewById(R.id.text);
735 divider = itemView.findViewById(R.id.last_seen_divider);
736 }
737
738 StickyHeaderViewHolder(TextView textView) {
739 super(textView);
740 this.textView = textView;
741 }
742
743 public void setText(CharSequence text) {
744 textView.setText(text);
745 }
746
747 public void setTextColor(@ColorInt int color) {
748 textView.setTextColor(color);
749 }
750
751 public void setBackgroundRes(@DrawableRes int resId) {
752 textView.setBackgroundResource(resId);
753 }
754
755 public void setDividerColor(@ColorInt int color) {
756 if (divider != null) {
757 divider.setBackgroundColor(color);
758 }
759 }
760
761 public void clearBackground() {
762 textView.setBackground(null);
763 }
764 }
765
766 public abstract static class HeaderFooterViewHolder extends RecyclerView.ViewHolder {
767
768 private ViewGroup container;
769
770 HeaderFooterViewHolder(@NonNull View itemView) {
771 super(itemView);
772 this.container = (ViewGroup) itemView;
773 }
774
775 void bind(@Nullable View view) {
776 unbind();
777
778 if (view != null) {
779 removeViewFromParent(view);
780 container.addView(view);
781 }
782 }
783
784 void unbind() {
785 container.removeAllViews();
786 }
787
788 private void removeViewFromParent(@NonNull View view) {
789 if (view.getParent() != null) {
790 ((ViewGroup) view.getParent()).removeView(view);
791 }
792 }
793 }
794
795 public static class FooterViewHolder extends HeaderFooterViewHolder {
796 FooterViewHolder(@NonNull View itemView) {
797 super(itemView);
798 setPaddingTop();
799 }
800
801 @Override
802 void bind(@Nullable View view) {
803 super.bind(view);
804 setPaddingTop();
805 }
806
807 private void setPaddingTop() {
808 if (Build.VERSION.SDK_INT <= 23) {
809 int addToPadding = ViewUtil.getStatusBarHeight(itemView) + (int) ThemeUtil.getThemedDimen(itemView.getContext(), android.R.attr.actionBarSize);
810 ViewUtil.setPaddingTop(itemView, itemView.getPaddingTop() + addToPadding);
811 }
812 }
813 }
814
815 public static class HeaderViewHolder extends HeaderFooterViewHolder {
816 HeaderViewHolder(@NonNull View itemView) {
817 super(itemView);
818 }
819 }
820
821 private static class PlaceholderViewHolder extends RecyclerView.ViewHolder {
822 PlaceholderViewHolder(@NonNull View itemView) {
823 super(itemView);
824 }
825 }
826
827 public interface ItemClickListener extends BindableConversationItem.EventListener {
828 void onItemClick(MultiselectPart item);
829 void onItemLongClick(View itemView, MultiselectPart item);
830 }
831}