Add support for typing indicators.

This commit is contained in:
Greyson Parrelli
2018-10-29 15:14:31 -07:00
parent 3f25fb7d5f
commit 776b0e23ae
39 changed files with 1231 additions and 60 deletions

View File

@@ -0,0 +1,54 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.PorterDuff;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.widget.LinearLayout;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.List;
public class ConversationTypingView extends LinearLayout {
private AvatarImageView avatar;
private View bubble;
private TypingIndicatorView indicator;
public ConversationTypingView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
avatar = findViewById(R.id.typing_avatar);
bubble = findViewById(R.id.typing_bubble);
indicator = findViewById(R.id.typing_indicator);
}
public void setTypists(@NonNull GlideRequests glideRequests, @NonNull List<Recipient> typists, boolean isGroupThread) {
if (typists.isEmpty()) {
indicator.stopAnimation();
return;
}
Recipient typist = typists.get(0);
bubble.getBackground().setColorFilter(typist.getColor().toConversationColor(getContext()), PorterDuff.Mode.MULTIPLY);
if (isGroupThread) {
avatar.setAvatar(glideRequests, typist, false);
avatar.setVisibility(VISIBLE);
} else {
avatar.setVisibility(GONE);
}
indicator.startAnimation();
}
}

View File

@@ -0,0 +1,115 @@
package org.thoughtcrime.securesms.components;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.widget.LinearLayout;
import org.thoughtcrime.securesms.R;
public class TypingIndicatorView extends LinearLayout {
private static final long DURATION = 300;
private static final long PRE_DELAY = 500;
private static final long POST_DELAY = 500;
private View dot1;
private View dot2;
private View dot3;
private AnimatorSet animation1;
private AnimatorSet animation2;
private AnimatorSet animation3;
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.typing_indicator_view, this);
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);
}
animation1 = getAnimation(dot1, DURATION, 0 );
animation2 = getAnimation(dot2, DURATION, DURATION / 2);
animation3 = getAnimation(dot3, DURATION, DURATION );
animation3.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
postDelayed(TypingIndicatorView.this::startAnimation, POST_DELAY);
}
});
}
public void startAnimation() {
stopAnimation();
postDelayed(() -> {
animation1.start();
animation2.start();
animation3.start();
}, PRE_DELAY);
}
public void stopAnimation() {
animation1.cancel();
animation2.cancel();
animation3.cancel();
reset(dot1);
reset(dot2);
reset(dot3);
}
private AnimatorSet getAnimation(@NonNull View view, long duration, long startDelay) {
AnimatorSet grow = new AnimatorSet();
grow.playTogether(ObjectAnimator.ofFloat(view, View.SCALE_X, 0.5f, 1).setDuration(duration),
ObjectAnimator.ofFloat(view, View.SCALE_Y, 0.5f, 1).setDuration(duration),
ObjectAnimator.ofFloat(view, View.ALPHA, 0.5f, 1).setDuration(duration));
AnimatorSet shrink = new AnimatorSet();
shrink.playTogether(ObjectAnimator.ofFloat(view, View.SCALE_X, 1, 0.5f).setDuration(duration),
ObjectAnimator.ofFloat(view, View.SCALE_Y, 1, 0.5f).setDuration(duration),
ObjectAnimator.ofFloat(view, View.ALPHA, 1, 0.5f).setDuration(duration));
AnimatorSet all = new AnimatorSet();
all.playSequentially(grow, shrink);
all.setStartDelay(startDelay);
return all;
}
private void reset(View view) {
view.clearAnimation();
view.setScaleX(0.5f);
view.setScaleY(0.5f);
view.setAlpha(0.5f);
}
}

View File

@@ -0,0 +1,184 @@
package org.thoughtcrime.securesms.components;
import android.annotation.SuppressLint;
import android.arch.lifecycle.LiveData;
import android.arch.lifecycle.MutableLiveData;
import android.support.annotation.NonNull;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.Util;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@SuppressLint("UseSparseArrays")
public class TypingStatusRepository {
private static final String TAG = TypingStatusRepository.class.getSimpleName();
private static final long RECIPIENT_TYPING_TIMEOUT = TimeUnit.SECONDS.toMillis(15);
private final Map<Long, Set<Typist>> typistMap;
private final Map<Typist, Runnable> timers;
private final Map<Long, MutableLiveData<TypingState>> notifiers;
private final MutableLiveData<Set<Long>> threadsNotifier;
public TypingStatusRepository() {
this.typistMap = new HashMap<>();
this.timers = new HashMap<>();
this.notifiers = new HashMap<>();
this.threadsNotifier = new MutableLiveData<>();
}
public synchronized void onTypingStarted(long threadId, Recipient author, int device) {
Set<Typist> typists = Util.getOrDefault(typistMap, threadId, new LinkedHashSet<>());
Typist typist = new Typist(author, device, threadId);
if (!typists.contains(typist)) {
typists.add(typist);
typistMap.put(threadId, typists);
notifyThread(threadId, typists, false);
}
Runnable timer = timers.get(typist);
if (timer != null) {
Util.cancelRunnableOnMain(timer);
}
timer = () -> onTypingStopped(threadId, author, device, false);
Util.runOnMainDelayed(timer, RECIPIENT_TYPING_TIMEOUT);
timers.put(typist, timer);
}
public synchronized void onTypingStopped(long threadId, Recipient author, int device, boolean isReplacedByIncomingMessage) {
Set<Typist> typists = Util.getOrDefault(typistMap, threadId, new LinkedHashSet<>());
Typist typist = new Typist(author, device, threadId);
if (typists.contains(typist)) {
typists.remove(typist);
notifyThread(threadId, typists, isReplacedByIncomingMessage);
}
if (typists.isEmpty()) {
typistMap.remove(threadId);
}
Runnable timer = timers.get(typist);
if (timer != null) {
Util.cancelRunnableOnMain(timer);
timers.remove(typist);
}
}
public synchronized LiveData<TypingState> getTypists(long threadId) {
MutableLiveData<TypingState> notifier = Util.getOrDefault(notifiers, threadId, new MutableLiveData<>());
notifiers.put(threadId, notifier);
return notifier;
}
public synchronized LiveData<Set<Long>> getTypingThreads() {
return threadsNotifier;
}
public synchronized void clear() {
TypingState empty = new TypingState(Collections.emptyList(), false);
for (MutableLiveData<TypingState> notifier : notifiers.values()) {
notifier.postValue(empty);
}
notifiers.clear();
typistMap.clear();
timers.clear();
threadsNotifier.postValue(Collections.emptySet());
}
private void notifyThread(long threadId, @NonNull Set<Typist> typists, boolean isReplacedByIncomingMessage) {
Log.d(TAG, "notifyThread() threadId: " + threadId + " typists: " + typists.size() + " isReplaced: " + isReplacedByIncomingMessage);
MutableLiveData<TypingState> notifier = Util.getOrDefault(notifiers, threadId, new MutableLiveData<>());
notifiers.put(threadId, notifier);
Set<Recipient> uniqueTypists = new LinkedHashSet<>();
for (Typist typist : typists) {
uniqueTypists.add(typist.getAuthor());
}
notifier.postValue(new TypingState(new ArrayList<>(uniqueTypists), isReplacedByIncomingMessage));
Set<Long> activeThreads = Stream.of(typistMap.keySet()).filter(t -> !typistMap.get(t).isEmpty()).collect(Collectors.toSet());
threadsNotifier.postValue(activeThreads);
}
public static class TypingState {
private final List<Recipient> typists;
private final boolean replacedByIncomingMessage;
public TypingState(List<Recipient> typists, boolean replacedByIncomingMessage) {
this.typists = typists;
this.replacedByIncomingMessage = replacedByIncomingMessage;
}
public List<Recipient> getTypists() {
return typists;
}
public boolean isReplacedByIncomingMessage() {
return replacedByIncomingMessage;
}
}
private static class Typist {
private final Recipient author;
private final int device;
private final long threadId;
private Typist(@NonNull Recipient author, int device, long threadId) {
this.author = author;
this.device = device;
this.threadId = threadId;
}
public Recipient getAuthor() {
return author;
}
public int getDevice() {
return device;
}
public long getThreadId() {
return threadId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Typist typist = (Typist) o;
if (device != typist.device) return false;
if (threadId != typist.threadId) return false;
return author.equals(typist.author);
}
@Override
public int hashCode() {
int result = author.hashCode();
result = 31 * result + device;
result = 31 * result + (int) (threadId ^ (threadId >>> 32));
return result;
}
}
}

View File

@@ -0,0 +1,117 @@
package org.thoughtcrime.securesms.components;
import android.annotation.SuppressLint;
import android.content.Context;
import android.support.annotation.NonNull;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.jobs.TypingSendJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@SuppressLint("UseSparseArrays")
public class TypingStatusSender {
private static final String TAG = TypingStatusSender.class.getSimpleName();
private static final long REFRESH_TYPING_TIMEOUT = TimeUnit.SECONDS.toMillis(10);
private static final long PAUSE_TYPING_TIMEOUT = TimeUnit.SECONDS.toMillis(3);
private final Context context;
private final Map<Long, TimerPair> selfTypingTimers;
public TypingStatusSender(@NonNull Context context) {
this.context = context;
this.selfTypingTimers = new HashMap<>();
}
public synchronized void onTypingStarted(long threadId) {
TimerPair pair = Util.getOrDefault(selfTypingTimers, threadId, new TimerPair());
selfTypingTimers.put(threadId, pair);
if (pair.getStart() == null) {
sendTyping(threadId, true);
Runnable start = new StartRunnable(threadId);
Util.runOnMainDelayed(start, REFRESH_TYPING_TIMEOUT);
pair.setStart(start);
}
if (pair.getStop() != null) {
Util.cancelRunnableOnMain(pair.getStop());
}
Runnable stop = () -> onTypingStopped(threadId, true);
Util.runOnMainDelayed(stop, PAUSE_TYPING_TIMEOUT);
pair.setStop(stop);
}
public synchronized void onTypingStopped(long threadId) {
onTypingStopped(threadId, false);
}
private synchronized void onTypingStopped(long threadId, boolean notify) {
TimerPair pair = Util.getOrDefault(selfTypingTimers, threadId, new TimerPair());
selfTypingTimers.put(threadId, pair);
if (pair.getStart() != null) {
Util.cancelRunnableOnMain(pair.getStart());
if (notify) {
sendTyping(threadId, false);
}
}
if (pair.getStop() != null) {
Util.cancelRunnableOnMain(pair.getStop());
}
pair.setStart(null);
pair.setStop(null);
}
private void sendTyping(long threadId, boolean typingStarted) {
ApplicationContext.getInstance(context).getJobManager().add(new TypingSendJob(context, threadId, typingStarted));
}
private class StartRunnable implements Runnable {
private final long threadId;
private StartRunnable(long threadId) {
this.threadId = threadId;
}
@Override
public void run() {
sendTyping(threadId, true);
Util.runOnMainDelayed(this, REFRESH_TYPING_TIMEOUT);
}
}
private static class TimerPair {
private Runnable start;
private Runnable stop;
public Runnable getStart() {
return start;
}
public void setStart(Runnable start) {
this.start = start;
}
public Runnable getStop() {
return stop;
}
public void setStop(Runnable stop) {
this.stop = stop;
}
}
}

View File

@@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.components.recyclerview;
import android.content.Context;
import android.support.annotation.NonNull;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.LinearSmoothScroller;
import android.util.DisplayMetrics;
public class SmoothScrollingLinearLayoutManager extends LinearLayoutManager {
public SmoothScrollingLinearLayoutManager(Context context, boolean reverseLayout) {
super(context, LinearLayoutManager.VERTICAL, reverseLayout);
}
public void smoothScrollToPosition(@NonNull Context context, int position, float millisecondsPerInch) {
final LinearSmoothScroller scroller = new LinearSmoothScroller(context) {
@Override
protected int getVerticalSnapPreference() {
return LinearSmoothScroller.SNAP_TO_END;
}
@Override
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
return millisecondsPerInch / displayMetrics.densityDpi;
}
};
scroller.setTargetPosition(position);
startSmoothScroll(scroller);
}
}