diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java index aad4c17008..0fd813cf4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java @@ -114,7 +114,7 @@ class MediaGalleryAdapter extends StickyHeaderGridAdapter { Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment()); if (slide != null) { - thumbnailView.setImageResource(glideRequests, slide, false, false); + thumbnailView.setImageResource(glideRequests, slide, false, null); } thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java b/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java deleted file mode 100644 index 5b2199896a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java +++ /dev/null @@ -1,158 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Canvas; -import android.graphics.Color; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.util.AttributeSet; -import android.view.View; -import android.view.ViewGroup; -import android.widget.FrameLayout; -import android.widget.TextView; - -import org.thoughtcrime.securesms.mms.GlideRequests; - -import org.thoughtcrime.securesms.mms.ImageSlide; -import org.thoughtcrime.securesms.mms.SlidesClickedListener; - -import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; - -import network.loki.messenger.R; -import okhttp3.HttpUrl; - -public class LinkPreviewView extends FrameLayout { - - private static final int TYPE_CONVERSATION = 0; - private static final int TYPE_COMPOSE = 1; - - private ViewGroup container; - private OutlinedThumbnailView thumbnail; - private TextView title; - private TextView site; - private View divider; - private View closeButton; - private View spinner; - - private int type; - private int defaultRadius; - private CornerMask cornerMask; - private Outliner outliner; - private CloseClickedListener closeClickedListener; - - public LinkPreviewView(Context context) { - super(context); - init(null); - } - - public LinkPreviewView(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - init(attrs); - } - - private void init(@Nullable AttributeSet attrs) { - inflate(getContext(), R.layout.link_preview, this); - - container = findViewById(R.id.linkpreview_container); - thumbnail = findViewById(R.id.linkpreview_thumbnail); - title = findViewById(R.id.linkpreview_title); - site = findViewById(R.id.linkpreview_site); - divider = findViewById(R.id.linkpreview_divider); - spinner = findViewById(R.id.linkpreview_progress_wheel); - closeButton = findViewById(R.id.linkpreview_close); - defaultRadius = getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius); - cornerMask = new CornerMask(this); - outliner = new Outliner(); - - outliner.setColor(getResources().getColor(R.color.transparent)); - - if (attrs != null) { - TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.LinkPreviewView, 0, 0); - type = typedArray.getInt(R.styleable.LinkPreviewView_linkpreview_type, 0); - typedArray.recycle(); - } - - if (type == TYPE_COMPOSE) { - container.setBackgroundColor(Color.TRANSPARENT); - container.setPadding(0, 0, 0, 0); - divider.setVisibility(VISIBLE); - - closeButton.setOnClickListener(v -> { - if (closeClickedListener != null) { - closeClickedListener.onCloseClicked(); - } - }); - } - - setWillNotDraw(false); - } - - @Override - protected void dispatchDraw(Canvas canvas) { - super.dispatchDraw(canvas); - if (type == TYPE_COMPOSE) return; - - cornerMask.mask(canvas); - outliner.draw(canvas); - } - - public void setLoading() { - title.setVisibility(GONE); - site.setVisibility(GONE); - thumbnail.setVisibility(GONE); - spinner.setVisibility(VISIBLE); - closeButton.setVisibility(GONE); - } - - public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail, boolean showCloseButton) { - setLinkPreview(glideRequests, linkPreview, showThumbnail); - if (showCloseButton) { - closeButton.setVisibility(VISIBLE); - } else { - closeButton.setVisibility(GONE); - } - } - - public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail) { - title.setVisibility(VISIBLE); - site.setVisibility(VISIBLE); - thumbnail.setVisibility(VISIBLE); - spinner.setVisibility(GONE); - closeButton.setVisibility(VISIBLE); - - title.setText(linkPreview.getTitle()); - - HttpUrl url = HttpUrl.parse(linkPreview.getUrl()); - if (url != null) { - site.setText(url.topPrivateDomain()); - } - - if (showThumbnail && linkPreview.getThumbnail().isPresent()) { - thumbnail.setVisibility(VISIBLE); - thumbnail.setImageResource(glideRequests, new ImageSlide(getContext(), linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION, false); - thumbnail.showDownloadText(false); - } else { - thumbnail.setVisibility(GONE); - } - } - - public void setCorners(int topLeft, int topRight) { - cornerMask.setRadii(topLeft, topRight, 0, 0); - outliner.setRadii(topLeft, topRight, 0, 0); - thumbnail.setCorners(topLeft, defaultRadius, defaultRadius, defaultRadius); - postInvalidate(); - } - - public void setCloseClickedListener(@Nullable CloseClickedListener closeClickedListener) { - this.closeClickedListener = closeClickedListener; - } - - public void setDownloadClickedListener(SlidesClickedListener listener) { - thumbnail.setDownloadClickListener(listener); - } - - public interface CloseClickedListener { - void onCloseClicked(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java deleted file mode 100644 index 71bf8a2804..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.graphics.Canvas; -import android.util.AttributeSet; - -import org.session.libsession.utilities.ThemeUtil; -import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView; - -import network.loki.messenger.R; - -public class OutlinedThumbnailView extends ThumbnailView { - - private CornerMask cornerMask; - private Outliner outliner; - - public OutlinedThumbnailView(Context context) { - super(context); - init(); - } - - public OutlinedThumbnailView(Context context, AttributeSet attrs) { - super(context, attrs); - init(); - } - - private void init() { - cornerMask = new CornerMask(this); - outliner = new Outliner(); - - outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_image_outline_color)); - setWillNotDraw(false); - } - - @Override - protected void dispatchDraw(Canvas canvas) { - super.dispatchDraw(canvas); - - cornerMask.mask(canvas); - outliner.draw(canvas); - } - - public void setCorners(int topLeft, int topRight, int bottomRight, int bottomLeft) { - cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft); - outliner.setRadii(topLeft, topRight, bottomRight, bottomLeft); - postInvalidate(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/StickerView.java b/app/src/main/java/org/thoughtcrime/securesms/components/StickerView.java index 6214c58531..98a623eef3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/StickerView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/StickerView.java @@ -52,19 +52,4 @@ public class StickerView extends FrameLayout { public void setOnLongClickListener(@Nullable OnLongClickListener l) { image.setOnLongClickListener(l); } - - public void setSticker(@NonNull GlideRequests glideRequests, @NonNull Slide stickerSlide) { - boolean showControls = stickerSlide.asAttachment().getDataUri() == null; - - image.setImageResource(glideRequests, stickerSlide, showControls, false); - missingShade.setVisibility(showControls ? View.VISIBLE : View.GONE); - } - - public void setThumbnailClickListener(@NonNull SlideClickListener listener) { - image.setThumbnailClickListener(listener); - } - - public void setDownloadClickListener(@NonNull SlidesClickedListener listener) { - image.setDownloadClickListener(listener); - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java index d512e0924c..211df4f205 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java @@ -24,7 +24,7 @@ public class EmojiTextView extends AppCompatTextView { private static final char ELLIPSIS = '…'; private CharSequence previousText; - private BufferType previousBufferType; + private BufferType previousBufferType = BufferType.NORMAL; private float originalFontSize; private boolean useSystemEmoji; private boolean sizeChangeInProgress; @@ -49,6 +49,12 @@ public class EmojiTextView extends AppCompatTextView { } @Override public void setText(@Nullable CharSequence text, BufferType type) { + // No need to do anything special if the text is null or empty + if (text == null || text.length() == 0) { + super.setText(text, type); + return; + } + EmojiParser.CandidateList candidates = EmojiProvider.getCandidates(text); if (scaleEmojis && candidates != null && candidates.allEmojis) { @@ -149,10 +155,15 @@ public class EmojiTextView extends AppCompatTextView { } private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) { - return Util.equals(previousText, text) && - Util.equals(previousOverflowText, overflowText) && - Util.equals(previousBufferType, bufferType) && - useSystemEmoji == useSystemEmoji() && + CharSequence finalPrevText = (previousText == null || previousText.length() == 0 ? "" : previousText); + CharSequence finalText = (text == null || text.length() == 0 ? "" : text); + CharSequence finalPrevOverflowText = (previousOverflowText == null || previousOverflowText.length() == 0 ? "" : previousOverflowText); + CharSequence finalOverflowText = (overflowText == null || overflowText.length() == 0 ? "" : overflowText); + + return Util.equals(finalPrevText, finalText) && + Util.equals(finalPrevOverflowText, finalOverflowText) && + Util.equals(previousBufferType, bufferType) && + useSystemEmoji == useSystemEmoji() && !sizeChangeInProgress; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt index 4d8e3c5b27..330534e232 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt @@ -8,6 +8,7 @@ import android.view.LayoutInflater import android.view.MotionEvent import android.view.ViewGroup import android.widget.FrameLayout +import android.widget.RelativeLayout import android.widget.TextView import androidx.core.view.children import androidx.core.view.isVisible @@ -18,41 +19,28 @@ import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAt import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.MediaPreviewActivity import org.thoughtcrime.securesms.components.CornerMask -import org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView +import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.util.ActivityDispatcher -class AlbumThumbnailView : FrameLayout { - - private lateinit var binding: AlbumThumbnailViewBinding - +class AlbumThumbnailView : RelativeLayout { companion object { const val MAX_ALBUM_DISPLAY_SIZE = 3 } + private val binding: AlbumThumbnailViewBinding by lazy { AlbumThumbnailViewBinding.bind(this) } + // region Lifecycle - constructor(context: Context) : super(context) { - initialize() - } - - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { - initialize() - } - - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { - initialize() - } + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val cornerMask by lazy { CornerMask(this) } private var slides: List = listOf() private var slideSize: Int = 0 - private fun initialize() { - binding = AlbumThumbnailViewBinding.inflate(LayoutInflater.from(context), this, true) - } - override fun dispatchDraw(canvas: Canvas?) { super.dispatchDraw(canvas) cornerMask.mask(canvas) @@ -67,11 +55,11 @@ class AlbumThumbnailView : FrameLayout { val eventRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt) val testRect = Rect() // test each album child - binding.albumCellContainer.findViewById(R.id.album_thumbnail_root)?.children?.forEachIndexed { index, child -> + binding.albumCellContainer.findViewById(R.id.album_thumbnail_root)?.children?.forEachIndexed forEach@{ index, child -> child.getGlobalVisibleRect(testRect) if (testRect.contains(eventRect)) { // hit intersects with this particular child - val slide = slides.getOrNull(index) ?: return + val slide = slides.getOrNull(index) ?: return@forEach // only open to downloaded images if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) { // Restart download here (on IO thread) @@ -79,7 +67,7 @@ class AlbumThumbnailView : FrameLayout { onAttachmentNeedsDownload(attachment.attachmentId.rowId, mms.getId()) } } - if (slide.isInProgress) return + if (slide.isInProgress) return@forEach ActivityDispatcher.get(context)?.dispatchIntent { context -> MediaPreviewActivity.getPreviewIntent(context, slide, mms, threadRecipient) @@ -130,7 +118,7 @@ class AlbumThumbnailView : FrameLayout { else -> R.layout.album_thumbnail_3 // three stacked with additional text } - fun getThumbnailView(position: Int): KThumbnailView = when (position) { + fun getThumbnailView(position: Int): ThumbnailView = when (position) { 0 -> binding.albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_1) 1 -> binding.albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_2) 2 -> binding.albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_3) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt index c1fce3f50b..66164f100f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt @@ -23,7 +23,7 @@ class LinkPreviewDraftView : LinearLayout { // Start out with the loader showing and the content view hidden binding = ViewLinkPreviewDraftBinding.inflate(LayoutInflater.from(context), this, true) binding.linkPreviewDraftContainer.isVisible = false - binding.thumbnailImageView.clipToOutline = true + binding.thumbnailImageView.root.clipToOutline = true binding.linkPreviewDraftCancelButton.setOnClickListener { cancel() } } @@ -31,10 +31,10 @@ class LinkPreviewDraftView : LinearLayout { // Hide the loader and show the content view binding.linkPreviewDraftContainer.isVisible = true binding.linkPreviewDraftLoader.isVisible = false - binding.thumbnailImageView.radius = toPx(4, resources) + binding.thumbnailImageView.root.radius = toPx(4, resources) if (linkPreview.getThumbnail().isPresent) { // This internally fetches the thumbnail - binding.thumbnailImageView.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, false) + binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, null) } binding.linkPreviewDraftTitleTextView.text = linkPreview.title } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorView.java deleted file mode 100644 index 826cfe7b3a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorView.java +++ /dev/null @@ -1,118 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.components; - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.PorterDuff; -import androidx.annotation.Nullable; -import android.util.AttributeSet; -import android.view.View; -import android.widget.LinearLayout; - -import network.loki.messenger.R; - -public class TypingIndicatorView extends LinearLayout { - private boolean isActive; - private long startTime; - - private static final long CYCLE_DURATION = 1500; - private static final long DOT_DURATION = 600; - private static final float MIN_ALPHA = 0.4f; - private static final float MIN_SCALE = 0.75f; - - private View dot1; - private View dot2; - private View dot3; - - public TypingIndicatorView(Context context) { - super(context); - initialize(null); - } - - public TypingIndicatorView(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - initialize(attrs); - } - - private void initialize(@Nullable AttributeSet attrs) { - inflate(getContext(), R.layout.view_typing_indicator, this); - - setWillNotDraw(false); - - dot1 = findViewById(R.id.typing_dot1); - dot2 = findViewById(R.id.typing_dot2); - dot3 = findViewById(R.id.typing_dot3); - - if (attrs != null) { - TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.TypingIndicatorView, 0, 0); - int tint = typedArray.getColor(R.styleable.TypingIndicatorView_typingIndicator_tint, Color.WHITE); - typedArray.recycle(); - - dot1.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY); - dot2.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY); - dot3.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY); - } - } - - @Override - protected void onDraw(Canvas canvas) { - if (!isActive) { - super.onDraw(canvas); - return; - } - - long timeInCycle = (System.currentTimeMillis() - startTime) % CYCLE_DURATION; - - render(dot1, timeInCycle, 0); - render(dot2, timeInCycle, 150); - render(dot3, timeInCycle, 300); - - super.onDraw(canvas); - postInvalidate(); - } - - private void render(View dot, long timeInCycle, long start) { - long end = start + DOT_DURATION; - long peak = start + (DOT_DURATION / 2); - - if (timeInCycle < start || timeInCycle > end) { - renderDefault(dot); - } else if (timeInCycle < peak) { - renderFadeIn(dot, timeInCycle, start); - } else { - renderFadeOut(dot, timeInCycle, peak); - } - } - - private void renderDefault(View dot) { - dot.setAlpha(MIN_ALPHA); - dot.setScaleX(MIN_SCALE); - dot.setScaleY(MIN_SCALE); - } - - private void renderFadeIn(View dot, long timeInCycle, long fadeInStart) { - float percent = (float) (timeInCycle - fadeInStart) / 300; - dot.setAlpha(MIN_ALPHA + (1 - MIN_ALPHA) * percent); - dot.setScaleX(MIN_SCALE + (1 - MIN_SCALE) * percent); - dot.setScaleY(MIN_SCALE + (1 - MIN_SCALE) * percent); - } - - private void renderFadeOut(View dot, long timeInCycle, long fadeOutStart) { - float percent = (float) (timeInCycle - fadeOutStart) / 300; - dot.setAlpha(1 - (1 - MIN_ALPHA) * percent); - dot.setScaleX(1 - (1 - MIN_SCALE) * percent); - dot.setScaleY(1 - (1 - MIN_SCALE) * percent); - } - - public void startAnimation() { - isActive = true; - startTime = System.currentTimeMillis(); - - postInvalidate(); - } - - public void stopAnimation() { - isActive = false; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorView.kt new file mode 100644 index 0000000000..d1310bffba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorView.kt @@ -0,0 +1,105 @@ +package org.thoughtcrime.securesms.conversation.v2.components + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.PorterDuff +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import network.loki.messenger.R +import network.loki.messenger.databinding.ViewTypingIndicatorBinding + +class TypingIndicatorView : LinearLayout { + companion object { + private const val CYCLE_DURATION: Long = 1500 + private const val DOT_DURATION: Long = 600 + private const val MIN_ALPHA = 0.4f + private const val MIN_SCALE = 0.75f + } + + private val binding: ViewTypingIndicatorBinding by lazy { + val binding = ViewTypingIndicatorBinding.bind(this) + + if (tint != -1) { + binding.typingDot1.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY) + binding.typingDot2.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY) + binding.typingDot3.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY) + } + + return@lazy binding + } + + private var isActive = false + private var startTime: Long = 0 + private var tint: Int = -1 + + constructor(context: Context) : super(context) { initialize(null) } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) } + + private fun initialize(attrs: AttributeSet?) { + setWillNotDraw(false) + + if (attrs != null) { + val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.TypingIndicatorView, 0, 0) + this.tint = typedArray.getColor(R.styleable.TypingIndicatorView_typingIndicator_tint, Color.WHITE) + typedArray.recycle() + } + } + + override fun onDraw(canvas: Canvas) { + if (!isActive) { + super.onDraw(canvas) + return + } + val timeInCycle = (System.currentTimeMillis() - startTime) % CYCLE_DURATION + render(binding.typingDot1, timeInCycle, 0) + render(binding.typingDot2, timeInCycle, 150) + render(binding.typingDot3, timeInCycle, 300) + super.onDraw(canvas) + postInvalidate() + } + + private fun render(dot: View?, timeInCycle: Long, start: Long) { + val end = start + DOT_DURATION + val peak = start + DOT_DURATION / 2 + if (timeInCycle < start || timeInCycle > end) { + renderDefault(dot) + } else if (timeInCycle < peak) { + renderFadeIn(dot, timeInCycle, start) + } else { + renderFadeOut(dot, timeInCycle, peak) + } + } + + private fun renderDefault(dot: View?) { + dot!!.alpha = MIN_ALPHA + dot.scaleX = MIN_SCALE + dot.scaleY = MIN_SCALE + } + + private fun renderFadeIn(dot: View?, timeInCycle: Long, fadeInStart: Long) { + val percent = (timeInCycle - fadeInStart).toFloat() / 300 + dot!!.alpha = MIN_ALPHA + (1 - MIN_ALPHA) * percent + dot.scaleX = MIN_SCALE + (1 - MIN_SCALE) * percent + dot.scaleY = MIN_SCALE + (1 - MIN_SCALE) * percent + } + + private fun renderFadeOut(dot: View?, timeInCycle: Long, fadeOutStart: Long) { + val percent = (timeInCycle - fadeOutStart).toFloat() / 300 + dot!!.alpha = 1 - (1 - MIN_ALPHA) * percent + dot.scaleX = 1 - (1 - MIN_SCALE) * percent + dot.scaleY = 1 - (1 - MIN_SCALE) * percent + } + + fun startAnimation() { + isActive = true + startTime = System.currentTimeMillis() + postInvalidate() + } + + fun stopAnimation() { + isActive = false + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt index 768d49146e..3077d227e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt @@ -19,7 +19,7 @@ class TypingIndicatorViewContainer : LinearLayout { } fun setTypists(typists: List) { - if (typists.isEmpty()) { binding.typingIndicator.stopAnimation(); return } - binding.typingIndicator.startAnimation() + if (typists.isEmpty()) { binding.typingIndicator.root.stopAnimation(); return } + binding.typingIndicator.root.startAnimation() } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.java deleted file mode 100644 index 6d16f1f421..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.java +++ /dev/null @@ -1,346 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.messages; - -import android.content.Context; -import android.content.res.TypedArray; -import android.os.Handler; -import android.os.Looper; -import android.util.AttributeSet; -import android.view.HapticFeedbackConstants; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.widget.LinearLayout; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.constraintlayout.widget.Group; -import androidx.core.content.ContextCompat; - -import com.google.android.flexbox.FlexboxLayout; -import com.google.android.flexbox.JustifyContent; - -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.ThemeUtil; -import org.thoughtcrime.securesms.components.emoji.EmojiImageView; -import org.thoughtcrime.securesms.components.emoji.EmojiUtil; -import org.thoughtcrime.securesms.conversation.v2.ViewUtil; -import org.thoughtcrime.securesms.database.model.MessageId; -import org.thoughtcrime.securesms.database.model.ReactionRecord; -import org.thoughtcrime.securesms.util.NumberUtil; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import network.loki.messenger.R; - -public class EmojiReactionsView extends LinearLayout implements View.OnTouchListener { - - // Normally 6dp, but we have 1dp left+right margin on the pills themselves - private final int OUTER_MARGIN = ViewUtil.dpToPx(2); - private static final int DEFAULT_THRESHOLD = 5; - - private List records; - private long messageId; - private ViewGroup container; - private Group showLess; - private VisibleMessageViewDelegate delegate; - private Handler gestureHandler = new Handler(Looper.getMainLooper()); - private Runnable pressCallback; - private Runnable longPressCallback; - private long onDownTimestamp = 0; - private static long longPressDurationThreshold = 250; - private static long maxDoubleTapInterval = 200; - private boolean extended = false; - - public EmojiReactionsView(Context context) { - super(context); - init(null); - } - - public EmojiReactionsView(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - init(attrs); - } - - private void init(@Nullable AttributeSet attrs) { - inflate(getContext(), R.layout.view_emoji_reactions, this); - - this.container = findViewById(R.id.layout_emoji_container); - this.showLess = findViewById(R.id.group_show_less); - - records = new ArrayList<>(); - - if (attrs != null) { - TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiReactionsView, 0, 0); - typedArray.recycle(); - } - } - - public void clear() { - this.records.clear(); - container.removeAllViews(); - } - - public void setReactions(long messageId, @NonNull List records, boolean outgoing, VisibleMessageViewDelegate delegate) { - this.delegate = delegate; - if (records.equals(this.records)) { - return; - } - - FlexboxLayout containerLayout = (FlexboxLayout) this.container; - containerLayout.setJustifyContent(outgoing ? JustifyContent.FLEX_END : JustifyContent.FLEX_START); - this.records.clear(); - this.records.addAll(records); - if (this.messageId != messageId) { - extended = false; - } - this.messageId = messageId; - - displayReactions(extended ? Integer.MAX_VALUE : DEFAULT_THRESHOLD); - } - - @Override - public boolean onTouch(View v, MotionEvent event) { - if (v.getTag() == null) return false; - - Reaction reaction = (Reaction) v.getTag(); - int action = event.getAction(); - if (action == MotionEvent.ACTION_DOWN) onDown(new MessageId(reaction.messageId, reaction.isMms)); - else if (action == MotionEvent.ACTION_CANCEL) removeLongPressCallback(); - else if (action == MotionEvent.ACTION_UP) onUp(reaction); - return true; - } - - private void displayReactions(int threshold) { - String userPublicKey = TextSecurePreferences.getLocalNumber(getContext()); - List reactions = buildSortedReactionsList(records, userPublicKey, threshold); - - container.removeAllViews(); - LinearLayout overflowContainer = new LinearLayout(getContext()); - overflowContainer.setOrientation(LinearLayout.HORIZONTAL); - int innerPadding = ViewUtil.dpToPx(4); - overflowContainer.setPaddingRelative(innerPadding,innerPadding,innerPadding,innerPadding); - - int pixelSize = ViewUtil.dpToPx(1); - - for (Reaction reaction : reactions) { - if (container.getChildCount() + 1 >= DEFAULT_THRESHOLD && threshold != Integer.MAX_VALUE && reactions.size() > threshold) { - if (overflowContainer.getParent() == null) { - container.addView(overflowContainer); - MarginLayoutParams overflowParams = (MarginLayoutParams) overflowContainer.getLayoutParams(); - overflowParams.height = ViewUtil.dpToPx(26); - overflowParams.setMargins(pixelSize, pixelSize, pixelSize, pixelSize); - overflowContainer.setLayoutParams(overflowParams); - overflowContainer.setBackground(ContextCompat.getDrawable(getContext(), R.drawable.reaction_pill_background)); - } - View pill = buildPill(getContext(), this, reaction, true); - pill.setOnClickListener(v -> { - extended = true; - displayReactions(Integer.MAX_VALUE); - }); - pill.findViewById(R.id.reactions_pill_count).setVisibility(View.GONE); - pill.findViewById(R.id.reactions_pill_spacer).setVisibility(View.GONE); - overflowContainer.addView(pill); - } else { - View pill = buildPill(getContext(), this, reaction, false); - pill.setTag(reaction); - pill.setOnTouchListener(this); - MarginLayoutParams params = (MarginLayoutParams) pill.getLayoutParams(); - params.setMargins(pixelSize, pixelSize, pixelSize, pixelSize); - pill.setLayoutParams(params); - container.addView(pill); - } - } - - int overflowChildren = overflowContainer.getChildCount(); - int negativeMargin = ViewUtil.dpToPx(-8); - for (int i = 0; i < overflowChildren; i++) { - View child = overflowContainer.getChildAt(i); - MarginLayoutParams childParams = (MarginLayoutParams) child.getLayoutParams(); - if ((i == 0 && overflowChildren > 1) || i + 1 < overflowChildren) { - // if first and there is more than one child, or we are not the last child then set negative right margin - childParams.setMargins(0,0, negativeMargin, 0); - child.setLayoutParams(childParams); - } - } - - if (threshold == Integer.MAX_VALUE) { - showLess.setVisibility(VISIBLE); - for (int id : showLess.getReferencedIds()) { - findViewById(id).setOnClickListener(view -> { - extended = false; - displayReactions(DEFAULT_THRESHOLD); - }); - } - } else { - showLess.setVisibility(GONE); - } - } - - private void onReactionClicked(Reaction reaction) { - if (reaction.messageId != 0) { - MessageId messageId = new MessageId(reaction.messageId, reaction.isMms); - delegate.onReactionClicked(reaction.emoji, messageId, reaction.userWasSender); - } - } - - private static @NonNull List buildSortedReactionsList(@NonNull List records, String userPublicKey, int threshold) { - Map counters = new LinkedHashMap<>(); - - for (ReactionRecord record : records) { - String baseEmoji = EmojiUtil.getCanonicalRepresentation(record.getEmoji()); - Reaction info = counters.get(baseEmoji); - - if (info == null) { - info = new Reaction(record.getMessageId(), record.isMms(), record.getEmoji(), record.getCount(), record.getSortId(), record.getDateReceived(), userPublicKey.equals(record.getAuthor())); - } else { - info.update(record.getEmoji(), record.getCount(), record.getDateReceived(), userPublicKey.equals(record.getAuthor())); - } - - counters.put(baseEmoji, info); - } - - List reactions = new ArrayList<>(counters.values()); - - Collections.sort(reactions, Collections.reverseOrder()); - - if (reactions.size() >= threshold + 2 && threshold != Integer.MAX_VALUE) { - List shortened = new ArrayList<>(threshold + 2); - shortened.addAll(reactions.subList(0, threshold + 2)); - return shortened; - } else { - return reactions; - } - } - - private static View buildPill(@NonNull Context context, @NonNull ViewGroup parent, @NonNull Reaction reaction, boolean isCompact) { - View root = LayoutInflater.from(context).inflate(R.layout.reactions_pill, parent, false); - EmojiImageView emojiView = root.findViewById(R.id.reactions_pill_emoji); - TextView countView = root.findViewById(R.id.reactions_pill_count); - View spacer = root.findViewById(R.id.reactions_pill_spacer); - - if (isCompact) { - root.setPaddingRelative(1,1,1,1); - ViewGroup.LayoutParams layoutParams = root.getLayoutParams(); - layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT; - root.setLayoutParams(layoutParams); - } - - if (reaction.emoji != null) { - emojiView.setImageEmoji(reaction.emoji); - - if (reaction.count >= 1) { - countView.setText(NumberUtil.getFormattedNumber(reaction.count)); - } else { - countView.setVisibility(GONE); - spacer.setVisibility(GONE); - } - } else { - emojiView.setVisibility(GONE); - spacer.setVisibility(GONE); - countView.setText(context.getString(R.string.ReactionsConversationView_plus, reaction.count)); - } - - if (reaction.userWasSender && !isCompact) { - root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected)); - countView.setTextColor(ThemeUtil.getThemedColor(context, R.attr.reactionsPillSelectedTextColor)); - } else { - if (!isCompact) { - root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background)); - } - } - - return root; - } - - private void onDown(MessageId messageId) { - removeLongPressCallback(); - Runnable newLongPressCallback = () -> { - performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); - if (delegate != null) { - delegate.onReactionLongClicked(messageId); - } - }; - this.longPressCallback = newLongPressCallback; - gestureHandler.postDelayed(newLongPressCallback, longPressDurationThreshold); - onDownTimestamp = new Date().getTime(); - } - - private void removeLongPressCallback() { - if (longPressCallback != null) { - gestureHandler.removeCallbacks(longPressCallback); - } - } - - private void onUp(Reaction reaction) { - if ((new Date().getTime() - onDownTimestamp) < longPressDurationThreshold) { - removeLongPressCallback(); - if (pressCallback != null) { - gestureHandler.removeCallbacks(pressCallback); - this.pressCallback = null; - } else { - Runnable newPressCallback = () -> { - onReactionClicked(reaction); - pressCallback = null; - }; - this.pressCallback = newPressCallback; - gestureHandler.postDelayed(newPressCallback, maxDoubleTapInterval); - } - } - } - - private static class Reaction implements Comparable { - private final long messageId; - private final boolean isMms; - private String emoji; - private long count; - private long sortIndex; - private long lastSeen; - private boolean userWasSender; - - Reaction(long messageId, boolean isMms, @Nullable String emoji, long count, long sortIndex, long lastSeen, boolean userWasSender) { - this.messageId = messageId; - this.isMms = isMms; - this.emoji = emoji; - this.count = count; - this.sortIndex = sortIndex; - this.lastSeen = lastSeen; - this.userWasSender = userWasSender; - } - - void update(@NonNull String emoji, long count, long lastSeen, boolean userWasSender) { - if (!this.userWasSender) { - if (userWasSender || lastSeen > this.lastSeen) { - this.emoji = emoji; - } - } - - this.count = this.count + count; - this.lastSeen = Math.max(this.lastSeen, lastSeen); - this.userWasSender = this.userWasSender || userWasSender; - } - - @NonNull Reaction merge(@NonNull Reaction other) { - this.count = this.count + other.count; - this.lastSeen = Math.max(this.lastSeen, other.lastSeen); - this.userWasSender = this.userWasSender || other.userWasSender; - return this; - } - - @Override - public int compareTo(Reaction rhs) { - Reaction lhs = this; - if (lhs.count == rhs.count ) { - return Long.compare(lhs.sortIndex, rhs.sortIndex); - } else { - return Long.compare(lhs.count, rhs.count); - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt new file mode 100644 index 0000000000..49e4b1044f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt @@ -0,0 +1,291 @@ +package org.thoughtcrime.securesms.conversation.v2.messages + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.view.* +import android.view.View.OnTouchListener +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import com.google.android.flexbox.JustifyContent +import network.loki.messenger.R +import network.loki.messenger.databinding.ViewEmojiReactionsBinding +import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber +import org.session.libsession.utilities.ThemeUtil +import org.thoughtcrime.securesms.components.emoji.EmojiImageView +import org.thoughtcrime.securesms.components.emoji.EmojiUtil +import org.thoughtcrime.securesms.conversation.v2.ViewUtil +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.util.NumberUtil.getFormattedNumber +import java.util.* + +class EmojiReactionsView : ConstraintLayout, OnTouchListener { + companion object { + private const val DEFAULT_THRESHOLD = 5 + private const val longPressDurationThreshold: Long = 250 + private const val maxDoubleTapInterval: Long = 200 + } + + private val binding: ViewEmojiReactionsBinding by lazy { ViewEmojiReactionsBinding.bind(this) } + + // Normally 6dp, but we have 1dp left+right margin on the pills themselves + private val OUTER_MARGIN = ViewUtil.dpToPx(2) + private var records: MutableList? = null + private var messageId: Long = 0 + private var delegate: VisibleMessageViewDelegate? = null + private val gestureHandler = Handler(Looper.getMainLooper()) + private var pressCallback: Runnable? = null + private var longPressCallback: Runnable? = null + private var onDownTimestamp: Long = 0 + private var extended = false + + constructor(context: Context) : super(context) { init(null) } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { init(attrs) } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { init(attrs) } + + private fun init(attrs: AttributeSet?) { + records = ArrayList() + + if (attrs != null) { + val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.EmojiReactionsView, 0, 0) + typedArray.recycle() + } + } + + fun clear() { + records!!.clear() + binding.layoutEmojiContainer.removeAllViews() + } + + fun setReactions(messageId: Long, records: List, outgoing: Boolean, delegate: VisibleMessageViewDelegate?) { + this.delegate = delegate + if (records == this.records) { + return + } + + binding.layoutEmojiContainer.justifyContent = if (outgoing) JustifyContent.FLEX_END else JustifyContent.FLEX_START + this.records!!.clear() + this.records!!.addAll(records) + if (this.messageId != messageId) { + extended = false + } + this.messageId = messageId + displayReactions(if (extended) Int.MAX_VALUE else DEFAULT_THRESHOLD) + } + + override fun onTouch(v: View, event: MotionEvent): Boolean { + if (v.tag == null) return false + val reaction = v.tag as Reaction + val action = event.action + if (action == MotionEvent.ACTION_DOWN) onDown(MessageId(reaction.messageId, reaction.isMms)) else if (action == MotionEvent.ACTION_CANCEL) removeLongPressCallback() else if (action == MotionEvent.ACTION_UP) onUp(reaction) + return true + } + + private fun displayReactions(threshold: Int) { + val userPublicKey = getLocalNumber(context) + val reactions = buildSortedReactionsList(records!!, userPublicKey, threshold) + binding.layoutEmojiContainer.removeAllViews() + val overflowContainer = LinearLayout(context) + overflowContainer.orientation = LinearLayout.HORIZONTAL + val innerPadding = ViewUtil.dpToPx(4) + overflowContainer.setPaddingRelative(innerPadding, innerPadding, innerPadding, innerPadding) + val pixelSize = ViewUtil.dpToPx(1) + for (reaction in reactions) { + if (binding.layoutEmojiContainer.childCount + 1 >= DEFAULT_THRESHOLD && threshold != Int.MAX_VALUE && reactions.size > threshold) { + if (overflowContainer.parent == null) { + binding.layoutEmojiContainer.addView(overflowContainer) + val overflowParams = overflowContainer.layoutParams as MarginLayoutParams + overflowParams.height = ViewUtil.dpToPx(26) + overflowParams.setMargins(pixelSize, pixelSize, pixelSize, pixelSize) + overflowContainer.layoutParams = overflowParams + overflowContainer.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background) + } + val pill = buildPill(context, this, reaction, true) + pill.setOnClickListener { v: View? -> + extended = true + displayReactions(Int.MAX_VALUE) + } + pill.findViewById(R.id.reactions_pill_count).visibility = GONE + pill.findViewById(R.id.reactions_pill_spacer).visibility = GONE + overflowContainer.addView(pill) + } else { + val pill = buildPill(context, this, reaction, false) + pill.tag = reaction + pill.setOnTouchListener(this) + val params = pill.layoutParams as MarginLayoutParams + params.setMargins(pixelSize, pixelSize, pixelSize, pixelSize) + pill.layoutParams = params + binding.layoutEmojiContainer.addView(pill) + } + } + val overflowChildren = overflowContainer.childCount + val negativeMargin = ViewUtil.dpToPx(-8) + for (i in 0 until overflowChildren) { + val child = overflowContainer.getChildAt(i) + val childParams = child.layoutParams as MarginLayoutParams + if (i == 0 && overflowChildren > 1 || i + 1 < overflowChildren) { + // if first and there is more than one child, or we are not the last child then set negative right margin + childParams.setMargins(0, 0, negativeMargin, 0) + child.layoutParams = childParams + } + } + if (threshold == Int.MAX_VALUE) { + binding.groupShowLess.visibility = VISIBLE + for (id in binding.groupShowLess.referencedIds) { + findViewById(id).setOnClickListener { view: View? -> + extended = false + displayReactions(DEFAULT_THRESHOLD) + } + } + } else { + binding.groupShowLess.visibility = GONE + } + } + + private fun buildSortedReactionsList(records: List, userPublicKey: String?, threshold: Int): List { + val counters: MutableMap = LinkedHashMap() + + records.forEach { + val baseEmoji = EmojiUtil.getCanonicalRepresentation(it.emoji) + val info = counters[baseEmoji] + + if (info == null) { + counters[baseEmoji] = Reaction(messageId, it.isMms, it.emoji, it.count, it.sortId, it.dateReceived, userPublicKey == it.author) + } + else { + info.update(it.emoji, it.count, it.dateReceived, userPublicKey == it.author) + } + } + + val reactions: List = ArrayList(counters.values) + Collections.sort(reactions, Collections.reverseOrder()) + + return if (reactions.size >= threshold + 2 && threshold != Int.MAX_VALUE) { + val shortened: MutableList = ArrayList(threshold + 2) + shortened.addAll(reactions.subList(0, threshold + 2)) + shortened + } else { + reactions + } + } + + private fun buildPill(context: Context, parent: ViewGroup, reaction: Reaction, isCompact: Boolean): View { + val root = LayoutInflater.from(context).inflate(R.layout.reactions_pill, parent, false) + val emojiView = root.findViewById(R.id.reactions_pill_emoji) + val countView = root.findViewById(R.id.reactions_pill_count) + val spacer = root.findViewById(R.id.reactions_pill_spacer) + if (isCompact) { + root.setPaddingRelative(1, 1, 1, 1) + val layoutParams = root.layoutParams + layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT + root.layoutParams = layoutParams + } + if (reaction.emoji != null) { + emojiView.setImageEmoji(reaction.emoji) + if (reaction.count >= 1) { + countView.text = getFormattedNumber(reaction.count) + } else { + countView.visibility = GONE + spacer.visibility = GONE + } + } else { + emojiView.visibility = GONE + spacer.visibility = GONE + countView.text = context.getString(R.string.ReactionsConversationView_plus, reaction.count) + } + if (reaction.userWasSender && !isCompact) { + root.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected) + countView.setTextColor(ThemeUtil.getThemedColor(context, R.attr.reactionsPillSelectedTextColor)) + } else { + if (!isCompact) { + root.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background) + } + } + return root + } + + private fun onReactionClicked(reaction: Reaction) { + if (reaction.messageId != 0L) { + val messageId = MessageId(reaction.messageId, reaction.isMms) + delegate!!.onReactionClicked(reaction.emoji!!, messageId, reaction.userWasSender) + } + } + + private fun onDown(messageId: MessageId) { + removeLongPressCallback() + val newLongPressCallback = Runnable { + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + if (delegate != null) { + delegate!!.onReactionLongClicked(messageId) + } + } + longPressCallback = newLongPressCallback + gestureHandler.postDelayed(newLongPressCallback, longPressDurationThreshold) + onDownTimestamp = Date().time + } + + private fun removeLongPressCallback() { + if (longPressCallback != null) { + gestureHandler.removeCallbacks(longPressCallback!!) + } + } + + private fun onUp(reaction: Reaction) { + if (Date().time - onDownTimestamp < longPressDurationThreshold) { + removeLongPressCallback() + if (pressCallback != null) { + gestureHandler.removeCallbacks(pressCallback!!) + pressCallback = null + } else { + val newPressCallback = Runnable { + onReactionClicked(reaction) + pressCallback = null + } + pressCallback = newPressCallback + gestureHandler.postDelayed(newPressCallback, maxDoubleTapInterval) + } + } + } + + internal class Reaction( + internal val messageId: Long, + internal val isMms: Boolean, + internal var emoji: String?, + internal var count: Long, + internal val sortIndex: Long, + internal var lastSeen: Long, + internal var userWasSender: Boolean + ) : Comparable { + fun update(emoji: String, count: Long, lastSeen: Long, userWasSender: Boolean) { + if (!this.userWasSender) { + if (userWasSender || lastSeen > this.lastSeen) { + this.emoji = emoji + } + } + this.count = this.count + count + this.lastSeen = Math.max(this.lastSeen, lastSeen) + this.userWasSender = this.userWasSender || userWasSender + } + + fun merge(other: Reaction): Reaction { + count = count + other.count + lastSeen = Math.max(lastSeen, other.lastSeen) + userWasSender = userWasSender || other.userWasSender + return this + } + + override fun compareTo(other: Reaction?): Int { + if (other == null) { return -1 } + + if (this.count == other.count) { + return this.sortIndex.compareTo(other.sortIndex) + } + + return this.count.compareTo(other.count) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt index 8a27bc4e53..45d353cc34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt @@ -4,11 +4,9 @@ import android.content.Context import android.graphics.Canvas import android.graphics.Rect import android.util.AttributeSet -import android.view.LayoutInflater import android.view.MotionEvent import android.widget.LinearLayout import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.res.ResourcesCompat import androidx.core.view.isVisible import network.loki.messenger.R import network.loki.messenger.databinding.ViewLinkPreviewBinding @@ -19,21 +17,16 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MessageBubbleUtiliti import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.ImageSlide -import org.thoughtcrime.securesms.util.UiModeUtilities class LinkPreviewView : LinearLayout { - private lateinit var binding: ViewLinkPreviewBinding + private val binding: ViewLinkPreviewBinding by lazy { ViewLinkPreviewBinding.bind(this) } private val cornerMask by lazy { CornerMask(this) } private var url: String? = null // region Lifecycle - constructor(context: Context) : super(context) { initialize() } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } - - private fun initialize() { - binding = ViewLinkPreviewBinding.inflate(LayoutInflater.from(context), this, true) - } + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) // endregion // region Updating @@ -48,8 +41,8 @@ class LinkPreviewView : LinearLayout { // Thumbnail if (linkPreview.getThumbnail().isPresent) { // This internally fetches the thumbnail - binding.thumbnailImageView.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message) - binding.thumbnailImageView.loadIndicator.isVisible = false + binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message) + binding.thumbnailImageView.root.loadIndicator.isVisible = false } // Title binding.titleTextView.text = linkPreview.title diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt index 91ab4c106d..4e91400430 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt @@ -93,7 +93,7 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? val backgroundColor = context.getAccentColor() binding.quoteViewAttachmentPreviewContainer.backgroundTintList = ColorStateList.valueOf(backgroundColor) binding.quoteViewAttachmentPreviewImageView.isVisible = false - binding.quoteViewAttachmentThumbnailImageView.isVisible = false + binding.quoteViewAttachmentThumbnailImageView.root.isVisible = false when { attachments.audioSlide != null -> { binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_microphone) @@ -108,9 +108,9 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? attachments.thumbnailSlide != null -> { val slide = attachments.thumbnailSlide!! // This internally fetches the thumbnail - binding.quoteViewAttachmentThumbnailImageView.radius = toPx(4, resources) - binding.quoteViewAttachmentThumbnailImageView.setImageResource(glide, slide, false, false) - binding.quoteViewAttachmentThumbnailImageView.isVisible = true + binding.quoteViewAttachmentThumbnailImageView.root.radius = toPx(4, resources) + binding.quoteViewAttachmentThumbnailImageView.root.setImageResource(glide, slide, false, null) + binding.quoteViewAttachmentThumbnailImageView.root.isVisible = true binding.quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.Slide_video) else resources.getString(R.string.Slide_image) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 334f1cf160..6e6d562cd3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -45,21 +45,17 @@ import org.thoughtcrime.securesms.util.getAccentColor import java.util.* import kotlin.math.roundToInt -class VisibleMessageContentView : LinearLayout { - private lateinit var binding: ViewVisibleMessageContentBinding +class VisibleMessageContentView : ConstraintLayout { + private val binding: ViewVisibleMessageContentBinding by lazy { ViewVisibleMessageContentBinding.bind(this) } var onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf() var onContentDoubleTap: (() -> Unit)? = null var delegate: VisibleMessageViewDelegate? = null var indexInAdapter: Int = -1 // region Lifecycle - constructor(context: Context) : super(context) { initialize() } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } - - private fun initialize() { - binding = ViewVisibleMessageContentBinding.inflate(LayoutInflater.from(context), this, true) - } + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) // endregion // region Updating @@ -86,7 +82,7 @@ class VisibleMessageContentView : LinearLayout { // reset visibilities / containers onContentClick.clear() - binding.albumThumbnailView.clearViews() + binding.albumThumbnailView.root.clearViews() onContentDoubleTap = null if (message.isDeleted) { @@ -94,11 +90,11 @@ class VisibleMessageContentView : LinearLayout { binding.deletedMessageView.root.bind(message, getTextColor(context, message)) binding.bodyTextView.isVisible = false binding.quoteView.root.isVisible = false - binding.linkPreviewView.isVisible = false + binding.linkPreviewView.root.isVisible = false binding.untrustedView.root.isVisible = false binding.voiceMessageView.root.isVisible = false binding.documentView.root.isVisible = false - binding.albumThumbnailView.isVisible = false + binding.albumThumbnailView.root.isVisible = false binding.openGroupInvitationView.root.isVisible = false return } else { @@ -110,12 +106,12 @@ class VisibleMessageContentView : LinearLayout { binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null - binding.linkPreviewView.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty() + binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty() binding.untrustedView.root.isVisible = !contactIsTrusted && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty() binding.voiceMessageView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.audioSlide != null binding.documentView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null - binding.albumThumbnailView.isVisible = mediaThumbnailMessage + binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation var hideBody = false @@ -162,8 +158,8 @@ class VisibleMessageContentView : LinearLayout { when { message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> { - binding.linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster) - onContentClick.add { event -> binding.linkPreviewView.calculateHit(event) } + binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster) + onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) } // Body text view is inside the link preview for layout convenience } message is MmsMessageRecord && message.slideDeck.audioSlide != null -> { @@ -200,21 +196,21 @@ class VisibleMessageContentView : LinearLayout { if (contactIsTrusted || message.isOutgoing) { // isStart and isEnd of cluster needed for calculating the mask for full bubble image groups // bind after add view because views are inflated and calculated during bind - binding.albumThumbnailView.bind( + binding.albumThumbnailView.root.bind( glideRequests = glide, message = message, isStart = isStartOfMessageCluster, isEnd = isEndOfMessageCluster ) - val layoutParams = binding.albumThumbnailView.layoutParams as ConstraintLayout.LayoutParams + val layoutParams = binding.albumThumbnailView.root.layoutParams as ConstraintLayout.LayoutParams layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f - binding.albumThumbnailView.layoutParams = layoutParams + binding.albumThumbnailView.root.layoutParams = layoutParams onContentClick.add { event -> - binding.albumThumbnailView.calculateHitObject(event, message, thread, onAttachmentNeedsDownload) + binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload) } } else { hideBody = true - binding.albumThumbnailView.clearViews() + binding.albumThumbnailView.root.clearViews() binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message)) onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } } @@ -246,7 +242,7 @@ class VisibleMessageContentView : LinearLayout { } private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean = - listOf(albumThumbnailView, linkPreviewView, voiceMessageView.root, quoteView.root).none { it.isVisible } + listOf(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible } private fun getBackground(isOutgoing: Boolean): Drawable { val backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone @@ -261,8 +257,8 @@ class VisibleMessageContentView : LinearLayout { binding.openGroupInvitationView.root, binding.documentView.root, binding.quoteView.root, - binding.linkPreviewView, - binding.albumThumbnailView, + binding.linkPreviewView.root, + binding.albumThumbnailView.root, binding.bodyTextView ).forEach { view: View -> view.isVisible = false } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 7a421298da..3a38da0bb2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -85,7 +85,7 @@ class VisibleMessageView : LinearLayout { var onPress: ((event: MotionEvent) -> Unit)? = null var onSwipeToReply: (() -> Unit)? = null var onLongPress: (() -> Unit)? = null - val messageContentView: VisibleMessageContentView by lazy { binding.messageContentView } + val messageContentView: VisibleMessageContentView by lazy { binding.messageContentView.root } companion object { const val swipeToReplyThreshold = 64.0f // dp @@ -108,7 +108,7 @@ class VisibleMessageView : LinearLayout { isHapticFeedbackEnabled = true setWillNotDraw(false) binding.messageInnerContainer.disableClipping() - binding.messageContentView.disableClipping() + binding.messageContentView.root.disableClipping() } // endregion @@ -210,26 +210,26 @@ class VisibleMessageView : LinearLayout { // Expiration timer updateExpirationTimer(message) // Emoji Reactions - val emojiLayoutParams = binding.emojiReactionsView.layoutParams as ConstraintLayout.LayoutParams + val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f - binding.emojiReactionsView.layoutParams = emojiLayoutParams + binding.emojiReactionsView.root.layoutParams = emojiLayoutParams if (message.reactions.isNotEmpty()) { val capabilities = lokiThreadDb.getOpenGroupChat(threadID)?.server?.let { lokiApiDb.getServerCapabilities(it) } if (capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase())) { - binding.emojiReactionsView.setReactions(message.id, message.reactions, message.isOutgoing, delegate) - binding.emojiReactionsView.isVisible = true + binding.emojiReactionsView.root.setReactions(message.id, message.reactions, message.isOutgoing, delegate) + binding.emojiReactionsView.root.isVisible = true } else { - binding.emojiReactionsView.isVisible = false + binding.emojiReactionsView.root.isVisible = false } } else { - binding.emojiReactionsView.isVisible = false + binding.emojiReactionsView.root.isVisible = false } // Populate content view - binding.messageContentView.indexInAdapter = indexInAdapter - binding.messageContentView.bind( + binding.messageContentView.root.indexInAdapter = indexInAdapter + binding.messageContentView.root.bind( message, isStartOfMessageCluster, isEndOfMessageCluster, @@ -239,8 +239,8 @@ class VisibleMessageView : LinearLayout { message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false), onAttachmentNeedsDownload ) - binding.messageContentView.delegate = delegate - onDoubleTap = { binding.messageContentView.onContentDoubleTap?.invoke() } + binding.messageContentView.root.delegate = delegate + onDoubleTap = { binding.messageContentView.root.onContentDoubleTap?.invoke() } } private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean { @@ -275,7 +275,7 @@ class VisibleMessageView : LinearLayout { private fun updateExpirationTimer(message: MessageRecord) { val container = binding.messageInnerContainer - val content = binding.messageContentView + val content = binding.messageContentView.root val expiration = binding.expirationTimerView val spacing = binding.messageContentSpacing container.removeAllViewsInLayout() @@ -326,7 +326,7 @@ class VisibleMessageView : LinearLayout { override fun onDraw(canvas: Canvas) { val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing) val iconSize = toPx(24, context.resources) - val left = binding.messageInnerContainer.left + binding.messageContentView.right + spacing + val left = binding.messageInnerContainer.left + binding.messageContentView.root.right + spacing val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.root.marginBottom - (iconSize / 2) val right = left + iconSize val bottom = top + iconSize @@ -348,7 +348,7 @@ class VisibleMessageView : LinearLayout { fun recycle() { binding.profilePictureView.root.recycle() - binding.messageContentView.recycle() + binding.messageContentView.root.recycle() } // endregion @@ -444,7 +444,7 @@ class VisibleMessageView : LinearLayout { } fun onContentClick(event: MotionEvent) { - binding.messageContentView.onContentClick.iterator().forEach { clickHandler -> clickHandler.invoke(event) } + binding.messageContentView.root.onContentClick.iterator().forEach { clickHandler -> clickHandler.invoke(event) } } private fun onPress(event: MotionEvent) { @@ -464,7 +464,7 @@ class VisibleMessageView : LinearLayout { } fun playVoiceMessage() { - binding.messageContentView.playVoiceMessage() + binding.messageContentView.root.playVoiceMessage() } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.java deleted file mode 100644 index 912253ecd8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.java +++ /dev/null @@ -1,425 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.utilities; - -import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade; - -import android.content.Context; -import android.content.res.TypedArray; -import android.net.Uri; -import android.util.AttributeSet; -import android.view.View; -import android.view.ViewGroup; -import android.widget.FrameLayout; -import android.widget.ImageView; - -import androidx.annotation.NonNull; -import androidx.annotation.UiThread; - -import com.bumptech.glide.RequestBuilder; -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.bumptech.glide.load.resource.bitmap.BitmapTransformation; -import com.bumptech.glide.load.resource.bitmap.CenterCrop; -import com.bumptech.glide.load.resource.bitmap.FitCenter; -import com.bumptech.glide.load.resource.bitmap.RoundedCorners; -import com.bumptech.glide.request.RequestOptions; - -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress; -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.ViewUtil; -import org.session.libsignal.utilities.ListenableFuture; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.SettableFuture; -import org.session.libsignal.utilities.guava.Optional; -import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget; -import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget; -import org.thoughtcrime.securesms.components.TransferControlView; -import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; -import org.thoughtcrime.securesms.mms.GlideRequest; -import org.thoughtcrime.securesms.mms.GlideRequests; -import org.thoughtcrime.securesms.mms.Slide; -import org.thoughtcrime.securesms.mms.SlideClickListener; -import org.thoughtcrime.securesms.mms.SlidesClickedListener; - -import java.util.Collections; -import java.util.Locale; - -import network.loki.messenger.R; - -public class ThumbnailView extends FrameLayout { - - private static final String TAG = ThumbnailView.class.getSimpleName(); - private static final int WIDTH = 0; - private static final int HEIGHT = 1; - private static final int MIN_WIDTH = 0; - private static final int MAX_WIDTH = 1; - private static final int MIN_HEIGHT = 2; - private static final int MAX_HEIGHT = 3; - - private ImageView image; - private View playOverlay; - private View loadIndicator; - private OnClickListener parentClickListener; - - private final int[] dimens = new int[2]; - private final int[] bounds = new int[4]; - private final int[] measureDimens = new int[2]; - - private Optional transferControls = Optional.absent(); - private SlideClickListener thumbnailClickListener = null; - private SlidesClickedListener downloadClickListener = null; - private Slide slide = null; - - public int radius; - - public ThumbnailView(Context context) { - this(context, null); - } - - public ThumbnailView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public ThumbnailView(final Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - - inflate(context, R.layout.thumbnail_view, this); - - this.image = findViewById(R.id.thumbnail_image); - this.playOverlay = findViewById(R.id.play_overlay); - this.loadIndicator = findViewById(R.id.thumbnail_load_indicator); - super.setOnClickListener(new ThumbnailClickDispatcher()); - - if (attrs != null) { - TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0); - bounds[MIN_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0); - bounds[MAX_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0); - bounds[MIN_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0); - bounds[MAX_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0); - radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0); - typedArray.recycle(); - } else { - radius = 0; - } - } - - @Override - protected void onMeasure(int originalWidthMeasureSpec, int originalHeightMeasureSpec) { - fillTargetDimensions(measureDimens, dimens, bounds); - if (measureDimens[WIDTH] == 0 && measureDimens[HEIGHT] == 0) { - super.onMeasure(originalWidthMeasureSpec, originalHeightMeasureSpec); - return; - } - - int finalWidth = measureDimens[WIDTH] + getPaddingLeft() + getPaddingRight(); - int finalHeight = measureDimens[HEIGHT] + getPaddingTop() + getPaddingBottom(); - - super.onMeasure(MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY)); - } - - @SuppressWarnings("SuspiciousNameCombination") - private void fillTargetDimensions(int[] targetDimens, int[] dimens, int[] bounds) { - int dimensFilledCount = getNonZeroCount(dimens); - int boundsFilledCount = getNonZeroCount(bounds); - - if (dimensFilledCount == 0 || boundsFilledCount == 0) { - targetDimens[WIDTH] = 0; - targetDimens[HEIGHT] = 0; - return; - } - - double naturalWidth = dimens[WIDTH]; - double naturalHeight = dimens[HEIGHT]; - - int minWidth = bounds[MIN_WIDTH]; - int maxWidth = bounds[MAX_WIDTH]; - int minHeight = bounds[MIN_HEIGHT]; - int maxHeight = bounds[MAX_HEIGHT]; - - if (dimensFilledCount > 0 && dimensFilledCount < dimens.length) { - throw new IllegalStateException(String.format(Locale.ENGLISH, "Width or height has been specified, but not both. Dimens: %f x %f", - naturalWidth, naturalHeight)); - } - if (boundsFilledCount > 0 && boundsFilledCount < bounds.length) { - throw new IllegalStateException(String.format(Locale.ENGLISH, "One or more min/max dimensions have been specified, but not all. Bounds: [%d, %d, %d, %d]", - minWidth, maxWidth, minHeight, maxHeight)); - } - - double measuredWidth = naturalWidth; - double measuredHeight = naturalHeight; - - boolean widthInBounds = measuredWidth >= minWidth && measuredWidth <= maxWidth; - boolean heightInBounds = measuredHeight >= minHeight && measuredHeight <= maxHeight; - - if (!widthInBounds || !heightInBounds) { - double minWidthRatio = naturalWidth / minWidth; - double maxWidthRatio = naturalWidth / maxWidth; - double minHeightRatio = naturalHeight / minHeight; - double maxHeightRatio = naturalHeight / maxHeight; - - if (maxWidthRatio > 1 || maxHeightRatio > 1) { - if (maxWidthRatio >= maxHeightRatio) { - measuredWidth /= maxWidthRatio; - measuredHeight /= maxWidthRatio; - } else { - measuredWidth /= maxHeightRatio; - measuredHeight /= maxHeightRatio; - } - - measuredWidth = Math.max(measuredWidth, minWidth); - measuredHeight = Math.max(measuredHeight, minHeight); - - } else if (minWidthRatio < 1 || minHeightRatio < 1) { - if (minWidthRatio <= minHeightRatio) { - measuredWidth /= minWidthRatio; - measuredHeight /= minWidthRatio; - } else { - measuredWidth /= minHeightRatio; - measuredHeight /= minHeightRatio; - } - - measuredWidth = Math.min(measuredWidth, maxWidth); - measuredHeight = Math.min(measuredHeight, maxHeight); - } - } - - targetDimens[WIDTH] = (int) measuredWidth; - targetDimens[HEIGHT] = (int) measuredHeight; - } - - private int getNonZeroCount(int[] vals) { - int count = 0; - for (int val : vals) { - if (val > 0) { - count++; - } - } - return count; - } - - @Override - public void setOnClickListener(OnClickListener l) { - parentClickListener = l; - } - - @Override - public void setFocusable(boolean focusable) { - super.setFocusable(focusable); - if (transferControls.isPresent()) transferControls.get().setFocusable(focusable); - } - - @Override - public void setClickable(boolean clickable) { - super.setClickable(clickable); - if (transferControls.isPresent()) transferControls.get().setClickable(clickable); - } - - private TransferControlView getTransferControls() { - if (!transferControls.isPresent()) { - transferControls = Optional.of(ViewUtil.inflateStub(this, R.id.transfer_controls_stub)); - } - return transferControls.get(); - } - - public void setBounds(int minWidth, int maxWidth, int minHeight, int maxHeight) { - bounds[MIN_WIDTH] = minWidth; - bounds[MAX_WIDTH] = maxWidth; - bounds[MIN_HEIGHT] = minHeight; - bounds[MAX_HEIGHT] = maxHeight; - - forceLayout(); - } - - @UiThread - public ListenableFuture setImageResource(@NonNull GlideRequests glideRequests, @NonNull Slide slide, - boolean showControls, boolean isPreview) - { - return setImageResource(glideRequests, slide, showControls, isPreview, 0, 0); - } - - @UiThread - public ListenableFuture setImageResource(@NonNull GlideRequests glideRequests, @NonNull Slide slide, - boolean showControls, boolean isPreview, - int naturalWidth, int naturalHeight) - { - if (showControls) { - getTransferControls().setSlide(slide); - getTransferControls().setDownloadClickListener(new DownloadClickDispatcher()); - } else if (transferControls.isPresent()) { - getTransferControls().setVisibility(View.GONE); - } - - if (slide.getThumbnailUri() != null && slide.hasPlayOverlay() && - (slide.getTransferState() == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview)) - { - this.playOverlay.setVisibility(View.VISIBLE); - } else { - this.playOverlay.setVisibility(View.GONE); - } - - if (Util.equals(slide, this.slide)) { - Log.i(TAG, "Not re-loading slide " + slide.asAttachment().getDataUri()); - return new SettableFuture<>(false); - } - - if (this.slide != null && this.slide.getFastPreflightId() != null && - this.slide.getFastPreflightId().equals(slide.getFastPreflightId())) - { - Log.i(TAG, "Not re-loading slide for fast preflight: " + slide.getFastPreflightId()); - this.slide = slide; - return new SettableFuture<>(false); - } - - Log.i(TAG, "loading part with id " + slide.asAttachment().getDataUri() - + ", progress " + slide.getTransferState() + ", fast preflight id: " + - slide.asAttachment().getFastPreflightId()); - - this.slide = slide; - - dimens[WIDTH] = naturalWidth; - dimens[HEIGHT] = naturalHeight; - invalidate(); - - SettableFuture result = new SettableFuture<>(); - - if (slide.getThumbnailUri() != null) { - buildThumbnailGlideRequest(glideRequests, slide).into(new GlideDrawableListeningTarget(image, result)); - } else if (slide.hasPlaceholder()) { - buildPlaceholderGlideRequest(glideRequests, slide).into(new GlideBitmapListeningTarget(image, result)); - } else { - glideRequests.load(R.drawable.ic_image_white_24dp).centerInside().into(image); - result.set(false); - } - - return result; - } - - public ListenableFuture setImageResource(@NonNull GlideRequests glideRequests, @NonNull Uri uri) { - SettableFuture future = new SettableFuture<>(); - - if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE); - - GlideRequest request = glideRequests.load(new DecryptableUri(uri)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .transition(withCrossFade()); - - if (radius > 0) { - request = request.transforms(new CenterCrop(), new RoundedCorners(radius)); - } else { - request = request.transforms(new CenterCrop()); - } - - request.into(new GlideDrawableListeningTarget(image, future)); - - return future; - } - - public void setThumbnailClickListener(SlideClickListener listener) { - this.thumbnailClickListener = listener; - } - - public void setDownloadClickListener(SlidesClickedListener listener) { - this.downloadClickListener = listener; - } - - public void clear(GlideRequests glideRequests) { - glideRequests.clear(image); - - if (transferControls.isPresent()) { - getTransferControls().clear(); - } - - slide = null; - } - - public void showDownloadText(boolean showDownloadText) { - getTransferControls().setShowDownloadText(showDownloadText); - } - - public void showProgressSpinner() { - getTransferControls().showProgressSpinner(); - } - - public void setLoadIndicatorVisibile(boolean visible) { - this.loadIndicator.setVisibility(visible ? VISIBLE : GONE); - } - - protected void setRadius(int radius) { - this.radius = radius; - } - - private GlideRequest buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) { - GlideRequest request = applySizing(glideRequests.load(new DecryptableUri(slide.getThumbnailUri())) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .transition(withCrossFade()), new CenterCrop()); - - if (slide.isInProgress()) return request; - else return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)); - } - - private RequestBuilder buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) { - return applySizing(glideRequests.asBitmap() - .load(slide.getPlaceholderRes(getContext().getTheme())) - .diskCacheStrategy(DiskCacheStrategy.NONE), new FitCenter()); - } - - private GlideRequest applySizing(@NonNull GlideRequest request, @NonNull BitmapTransformation fitting) { - int[] size = new int[2]; - fillTargetDimensions(size, dimens, bounds); - if (size[WIDTH] == 0 && size[HEIGHT] == 0) { - size[WIDTH] = getDefaultWidth(); - size[HEIGHT] = getDefaultHeight(); - } - - request = request.override(size[WIDTH], size[HEIGHT]); - - if (radius > 0) { - return request.transforms(fitting, new RoundedCorners(radius)); - } else { - return request.transforms(fitting); - } - } - - private int getDefaultWidth() { - ViewGroup.LayoutParams params = getLayoutParams(); - if (params != null) { - return Math.max(params.width, 0); - } - return 0; - } - - private int getDefaultHeight() { - ViewGroup.LayoutParams params = getLayoutParams(); - if (params != null) { - return Math.max(params.height, 0); - } - return 0; - } - - private class ThumbnailClickDispatcher implements View.OnClickListener { - - @Override - public void onClick(View view) { - if (thumbnailClickListener != null && - slide != null && - slide.asAttachment().getDataUri() != null && - slide.getTransferState() == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE) - { - thumbnailClickListener.onClick(view, slide); - } else if (parentClickListener != null) { - parentClickListener.onClick(view); - } - } - } - - private class DownloadClickDispatcher implements View.OnClickListener { - - @Override - public void onClick(View view) { - if (downloadClickListener != null && slide != null) { - downloadClickListener.onClick(view, Collections.singletonList(slide)); - } else { - Log.w(TAG, "Received a download button click, but unable to execute it. slide: " + String.valueOf(slide) + " downloadClickListener: " + String.valueOf(downloadClickListener)); - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt similarity index 76% rename from app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt index 1ae2902188..275947a819 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt @@ -2,14 +2,11 @@ package org.thoughtcrime.securesms.conversation.v2.utilities import android.content.Context import android.graphics.Bitmap -import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.net.Uri import android.util.AttributeSet -import android.view.LayoutInflater import android.view.View import android.widget.FrameLayout -import androidx.core.content.ContextCompat import androidx.core.view.isVisible import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.bitmap.CenterCrop @@ -29,31 +26,33 @@ import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri import org.thoughtcrime.securesms.mms.GlideRequest import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.Slide +import kotlin.Boolean +import kotlin.Int +import kotlin.getValue +import kotlin.lazy +import kotlin.let -open class KThumbnailView: FrameLayout { - private lateinit var binding: ThumbnailViewBinding +open class ThumbnailView: FrameLayout { companion object { private const val WIDTH = 0 private const val HEIGHT = 1 } + private val binding: ThumbnailViewBinding by lazy { ThumbnailViewBinding.bind(this) } + // region Lifecycle constructor(context: Context) : super(context) { initialize(null) } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) } - private val image by lazy { binding.thumbnailImage } - private val playOverlay by lazy { binding.playOverlay } val loadIndicator: View by lazy { binding.thumbnailLoadIndicator } - val downloadIndicator: View by lazy { binding.thumbnailDownloadIcon } private val dimensDelegate = ThumbnailDimensDelegate() private var slide: Slide? = null - private var radius: Int = 0 + var radius: Int = 0 private fun initialize(attrs: AttributeSet?) { - binding = ThumbnailViewBinding.inflate(LayoutInflater.from(context), this) if (attrs != null) { val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0) @@ -66,8 +65,6 @@ open class KThumbnailView: FrameLayout { typedArray.recycle() } - val background = ContextCompat.getColor(context, R.color.transparent_black_6) - binding.root.background = ColorDrawable(background) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -80,8 +77,8 @@ open class KThumbnailView: FrameLayout { val finalHeight: Int = adjustedDimens[HEIGHT] + paddingTop + paddingBottom super.onMeasure( - MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY) + MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY) ) } @@ -90,17 +87,17 @@ open class KThumbnailView: FrameLayout { // endregion // region Interaction - fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, mms: MmsMessageRecord): ListenableFuture { + fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, mms: MmsMessageRecord?): ListenableFuture { return setImageResource(glide, slide, isPreview, 0, 0, mms) } fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, naturalWidth: Int, - naturalHeight: Int, mms: MmsMessageRecord): ListenableFuture { + naturalHeight: Int, mms: MmsMessageRecord?): ListenableFuture { val currentSlide = this.slide - playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() && + binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() && (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview)) if (equals(currentSlide, slide)) { @@ -116,8 +113,8 @@ open class KThumbnailView: FrameLayout { this.slide = slide - loadIndicator.isVisible = slide.isInProgress - downloadIndicator.isVisible = slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED + binding.thumbnailLoadIndicator.isVisible = slide.isInProgress + binding.thumbnailDownloadIcon.isVisible = slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED dimensDelegate.setDimens(naturalWidth, naturalHeight) invalidate() @@ -126,13 +123,13 @@ open class KThumbnailView: FrameLayout { when { slide.thumbnailUri != null -> { - buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(image, result)) + buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(binding.thumbnailImage, result)) } slide.hasPlaceholder() -> { - buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(image, result)) + buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(binding.thumbnailImage, result)) } else -> { - glide.clear(image) + glide.clear(binding.thumbnailImage) result.set(false) } } @@ -176,7 +173,7 @@ open class KThumbnailView: FrameLayout { } open fun clear(glideRequests: GlideRequests) { - glideRequests.clear(image) + glideRequests.clear(binding.thumbnailImage) slide = null } @@ -193,11 +190,35 @@ open class KThumbnailView: FrameLayout { request.transforms(CenterCrop()) } - request.into(GlideDrawableListeningTarget(image, future)) + request.into(GlideDrawableListeningTarget(binding.thumbnailImage, future)) return future } +// fun showDownloadText(showDownloadText: Boolean) { +// getTransferControls()?.setShowDownloadText(showDownloadText); +// } +// +// fun setDownloadClickListener(listener: SlidesClickedListener) { +// this.downloadClickListener = listener; +// } +// +// private fun getTransferControls(): TransferControlView? { +// if (transferControls == null) { +// transferControls = ViewUtil.inflateStub(this, R.id.transfer_controls_stub); +// } +// +// return transferControls +// } // endregion +// private class DownloadClickDispatcher : OnClickListener { +// override fun onClick(view: View?) { +// if (downloadClickListener != null && slide != null) { +// downloadClickListener.onClick(view, Collections.singletonList(slide)) +// } else { +// Log.w(TAG, "Received a download button click, but unable to execute it. slide: " + String.valueOf(slide) + " downloadClickListener: " + String.valueOf(downloadClickListener)) +// } +// } +// } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index bfa9b14489..7a0c865a42 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -99,11 +99,11 @@ class ConversationView : LinearLayout { binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE if (isTyping) { - binding.typingIndicatorView.startAnimation() + binding.typingIndicatorView.root.startAnimation() } else { - binding.typingIndicatorView.stopAnimation() + binding.typingIndicatorView.root.stopAnimation() } - binding.typingIndicatorView.visibility = if (isTyping) View.VISIBLE else View.GONE + binding.typingIndicatorView.root.visibility = if (isTyping) View.VISIBLE else View.GONE binding.statusIndicatorImageView.visibility = View.VISIBLE when { !thread.isOutgoing -> binding.statusIndicatorImageView.visibility = View.GONE diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 45f3b4a63f..f1a9c8ed94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -202,7 +202,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), OpenGroupManager.startPolling() JobQueue.shared.resumePendingJobs() } - // Set up typing observer + withContext(Dispatchers.Main) { updateProfileButton() TextSecurePreferences.events.filter { it == TextSecurePreferences.PROFILE_NAME_PREF }.collect { @@ -365,6 +365,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), setupMessageRequestsBanner() updateEmptyState() } + + ApplicationContext.getInstance(this@HomeActivity).typingStatusRepository.typingThreads.observe(this) { threadIds -> + homeAdapter.typingThreadIDs = (threadIds ?: setOf()) + } } private fun updateEmptyState() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt index 3efa841b54..0effc43fb2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -63,6 +63,8 @@ class HomeAdapter( lateinit var glide: GlideRequests var typingThreadIDs = setOf() set(value) { + if (field == value) { return } + field = value // TODO: replace this with a diffed update or a partial change set with payloads notifyDataSetChanged() diff --git a/app/src/main/res/layout/album_thumbnail_1.xml b/app/src/main/res/layout/album_thumbnail_1.xml index cf0f5d4892..cee81ba3e3 100644 --- a/app/src/main/res/layout/album_thumbnail_1.xml +++ b/app/src/main/res/layout/album_thumbnail_1.xml @@ -6,7 +6,7 @@ android:layout_width="@dimen/media_bubble_default_dimens" android:layout_height="@dimen/media_bubble_default_dimens"> - - - - - - - @@ -20,4 +21,4 @@ android:layout_gravity="center" android:layout="@layout/transfer_controls_stub" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/link_preview.xml b/app/src/main/res/layout/link_preview.xml deleted file mode 100644 index f76ad1010c..0000000000 --- a/app/src/main/res/layout/link_preview.xml +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/media_overview_gallery_item.xml b/app/src/main/res/layout/media_overview_gallery_item.xml index 6072611758..a4c3f324af 100644 --- a/app/src/main/res/layout/media_overview_gallery_item.xml +++ b/app/src/main/res/layout/media_overview_gallery_item.xml @@ -5,7 +5,7 @@ android:layout_height="match_parent" android:padding="2dp"> - - - + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/transparent_black_6"> - + diff --git a/app/src/main/res/layout/view_conversation.xml b/app/src/main/res/layout/view_conversation.xml index 8f26f17c77..04833b6a96 100644 --- a/app/src/main/res/layout/view_conversation.xml +++ b/app/src/main/res/layout/view_conversation.xml @@ -34,64 +34,69 @@ android:layout_gravity="center_vertical" android:orientation="vertical"> - + android:layout_height="wrap_content"> - + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toStartOf="@id/unreadCountIndicator" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constrainedWidth="true" + app:layout_constraintHorizontal_chainStyle="packed" + app:layout_constraintHorizontal_bias="0" + android:drawablePadding="4dp" + android:maxLines="1" + android:ellipsize="end" + android:textAlignment="viewStart" + android:textSize="@dimen/medium_font_size" + android:textStyle="bold" + android:textColor="?android:textColorPrimary" + app:drawableTint="?conversation_pinned_icon_color" + tools:drawableRight="@drawable/ic_pin" + tools:text="I'm a very long display name. What are you going to do about it?" /> + + + tools:text="8" + tools:textColor="?android:textColorPrimary" /> - - - - - - - + - + - - - @@ -65,4 +66,4 @@ android:visibility="gone" app:constraint_referenced_ids="image_view_show_less, text_view_show_less"/> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/view_link_preview.xml b/app/src/main/res/layout/view_link_preview.xml index 7e209c2a9b..dd2e133bea 100644 --- a/app/src/main/res/layout/view_link_preview.xml +++ b/app/src/main/res/layout/view_link_preview.xml @@ -1,8 +1,9 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/view_link_preview_draft.xml b/app/src/main/res/layout/view_link_preview_draft.xml index 65e2cf7fd5..bf7cd3ebb3 100644 --- a/app/src/main/res/layout/view_link_preview_draft.xml +++ b/app/src/main/res/layout/view_link_preview_draft.xml @@ -26,7 +26,7 @@ android:src="@drawable/ic_link" app:tint="?android:textColorPrimary" /> - - - - + android:layout_height="match_parent"> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/view_visible_message.xml b/app/src/main/res/layout/view_visible_message.xml index 48c2d1d8e0..d36a5dfdeb 100644 --- a/app/src/main/res/layout/view_visible_message.xml +++ b/app/src/main/res/layout/view_visible_message.xml @@ -1,5 +1,6 @@ - - @@ -104,7 +105,7 @@ - - - - - \ No newline at end of file + \ No newline at end of file