mirror of
https://github.com/oxen-io/session-android.git
synced 2025-10-23 17:48:50 +00:00
Add support for typing indicators.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user