feat: Add conversation pinning (#806)

* feat: Add conversation pinning

* Update pinned conversation icon

* Update pinned conversation column name
This commit is contained in:
ceokot 2021-12-10 01:18:56 +02:00 committed by GitHub
parent 546a6ec3f7
commit 15f5ac10ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 184 additions and 31 deletions

View File

@ -90,6 +90,7 @@ public class ThreadDatabase extends Database {
public static final String EXPIRES_IN = "expires_in";
public static final String LAST_SEEN = "last_seen";
private static final String HAS_SENT = "has_sent";
public static final String IS_PINNED = "is_pinned";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" +
ID + " INTEGER PRIMARY KEY, " + DATE + " INTEGER DEFAULT 0, " +
@ -109,7 +110,7 @@ public class ThreadDatabase extends Database {
private static final String[] THREAD_PROJECTION = {
ID, DATE, MESSAGE_COUNT, ADDRESS, SNIPPET, SNIPPET_CHARSET, READ, UNREAD_COUNT, TYPE, ERROR, SNIPPET_TYPE,
SNIPPET_URI, ARCHIVED, STATUS, DELIVERY_RECEIPT_COUNT, EXPIRES_IN, LAST_SEEN, READ_RECEIPT_COUNT
SNIPPET_URI, ARCHIVED, STATUS, DELIVERY_RECEIPT_COUNT, EXPIRES_IN, LAST_SEEN, READ_RECEIPT_COUNT, IS_PINNED
};
private static final List<String> TYPED_THREAD_PROJECTION = Stream.of(THREAD_PROJECTION)
@ -121,6 +122,11 @@ public class ThreadDatabase extends Database {
Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION))
.toList();
public static String getCreatePinnedCommand() {
return "ALTER TABLE "+ TABLE_NAME + " " +
"ADD COLUMN " + IS_PINNED + " INTEGER DEFAULT 0;";
}
public ThreadDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
}
@ -577,6 +583,16 @@ public class ThreadDatabase extends Database {
}
}
public void setPinned(long threadId, boolean pinned) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(IS_PINNED, pinned ? 1 : 0);
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE,
new String[] {String.valueOf(threadId)});
notifyConversationListeners(threadId);
}
private boolean deleteThreadOnEmpty(long threadId) {
Recipient threadRecipient = getRecipientForThreadId(threadId);
return threadRecipient != null && !threadRecipient.isOpenGroupRecipient();
@ -622,7 +638,7 @@ public class ThreadDatabase extends Database {
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
" ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
" WHERE " + where +
" ORDER BY " + TABLE_NAME + "." + DATE + " DESC";
" ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + DATE + " DESC";
if (limit > 0) {
query += " LIMIT " + limit;
@ -683,6 +699,7 @@ public class ThreadDatabase extends Database {
long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.EXPIRES_IN));
long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN));
Uri snippetUri = getSnippetUri(cursor);
boolean pinned = cursor.getInt(cursor.getColumnIndex(ThreadDatabase.IS_PINNED)) != 0;
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
readReceiptCount = 0;
@ -690,7 +707,7 @@ public class ThreadDatabase extends Database {
return new ThreadRecord(body, snippetUri, recipient, date, count,
unreadCount, threadId, deliveryReceiptCount, status, type,
distributionType, archived, expiresIn, lastSeen, readReceiptCount);
distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned);
}
private @Nullable Uri getSnippetUri(Cursor cursor) {

View File

@ -60,9 +60,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV26 = 47;
private static final int lokiV27 = 48;
private static final int lokiV28 = 49;
private static final int lokiV29 = 50;
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
private static final int DATABASE_VERSION = lokiV28;
private static final int DATABASE_VERSION = lokiV29;
private static final String DATABASE_NAME = "signal.db";
private final Context context;
@ -134,6 +135,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(LokiMessageDatabase.getUpdateMessageMappingTable());
db.execSQL(SessionContactDatabase.getCreateSessionContactTableCommand());
db.execSQL(RecipientDatabase.getCreateNotificationTypeCommand());
db.execSQL(ThreadDatabase.getCreatePinnedCommand());
executeStatements(db, SmsDatabase.CREATE_INDEXS);
executeStatements(db, MmsDatabase.CREATE_INDEXS);
@ -308,6 +310,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(LokiMessageDatabase.getCreateMessageHashTableCommand());
}
if (oldVersion < lokiV29) {
db.execSQL(ThreadDatabase.getCreatePinnedCommand());
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();

View File

@ -49,12 +49,13 @@ public class ThreadRecord extends DisplayRecord {
private final boolean archived;
private final long expiresIn;
private final long lastSeen;
private final boolean pinned;
public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri,
@NonNull Recipient recipient, long date, long count, int unreadCount,
long threadId, int deliveryReceiptCount, int status, long snippetType,
int distributionType, boolean archived, long expiresIn, long lastSeen,
int readReceiptCount)
int readReceiptCount, boolean pinned)
{
super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount);
this.snippetUri = snippetUri;
@ -64,6 +65,7 @@ public class ThreadRecord extends DisplayRecord {
this.archived = archived;
this.expiresIn = expiresIn;
this.lastSeen = lastSeen;
this.pinned = pinned;
}
public @Nullable Uri getSnippetUri() {
@ -163,4 +165,8 @@ public class ThreadRecord extends DisplayRecord {
public long getLastSeen() {
return lastSeen;
}
public boolean isPinned() {
return pinned;
}
}

View File

@ -8,18 +8,20 @@ import androidx.core.view.isVisible
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kotlinx.android.synthetic.main.fragment_conversation_bottom_sheet.*
import network.loki.messenger.R
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.util.UiModeUtilities
public class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListener {
//FIXME AC: Supplying a recipient directly into the field from an activity
//FIXME AC: Supplying a threadRecord directly into the field from an activity
// is not the best idea. It doesn't survive configuration change.
// We should be dealing with IDs and all sorts of serializable data instead
// if we want to use dialog fragments properly.
lateinit var recipient: Recipient
lateinit var thread: ThreadRecord
var onViewDetailsTapped: (() -> Unit?)? = null
var onPinTapped: (() -> Unit)? = null
var onUnpinTapped: (() -> Unit)? = null
var onBlockTapped: (() -> Unit)? = null
var onUnblockTapped: (() -> Unit)? = null
var onDeleteTapped: (() -> Unit)? = null
@ -33,6 +35,8 @@ public class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.
override fun onClick(v: View?) {
when (v) {
detailsTextView -> onViewDetailsTapped?.invoke()
pinTextView -> onPinTapped?.invoke()
unpinTextView -> onUnpinTapped?.invoke()
blockTextView -> onBlockTapped?.invoke()
unblockTextView -> onUnblockTapped?.invoke()
deleteTextView -> onDeleteTapped?.invoke()
@ -44,7 +48,8 @@ public class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (!this::recipient.isInitialized) { return dismiss() }
if (!this::thread.isInitialized) { return dismiss() }
val recipient = thread.recipient
if (!recipient.isGroupRecipient && !recipient.isLocalNumber) {
detailsTextView.visibility = View.VISIBLE
unblockTextView.visibility = if (recipient.isBlocked) View.VISIBLE else View.GONE
@ -62,6 +67,10 @@ public class ConversationOptionsBottomSheet : BottomSheetDialogFragment(), View.
notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted
notificationsTextView.setOnClickListener(this)
deleteTextView.setOnClickListener(this)
pinTextView.isVisible = !thread.isPinned
unpinTextView.isVisible = thread.isPinned
pinTextView.setOnClickListener(this)
unpinTextView.setOnClickListener(this)
}
override fun onStart() {

View File

@ -19,7 +19,7 @@ import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.DateUtils
import java.util.*
import java.util.Locale
class ConversationView : LinearLayout {
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
@ -39,6 +39,13 @@ class ConversationView : LinearLayout {
// region Updating
fun bind(thread: ThreadRecord, isTyping: Boolean, glide: GlideRequests) {
this.thread = thread
if (thread.isPinned) {
conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_pin, 0)
background = ContextCompat.getDrawable(context, R.drawable.conversation_pinned_background)
} else {
conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
background = ContextCompat.getDrawable(context, R.drawable.conversation_view_background)
}
profilePictureView.glide = glide
val unreadCount = thread.unreadCount
if (thread.recipient.isBlocked) {

View File

@ -59,11 +59,11 @@ import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate
import org.thoughtcrime.securesms.preferences.SettingsActivity
import org.thoughtcrime.securesms.util.*
import java.io.IOException
import java.util.*
import javax.inject.Inject
@AndroidEntryPoint
class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickListener, SeedReminderViewDelegate, NewConversationButtonSetViewDelegate {
class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickListener,
SeedReminderViewDelegate, NewConversationButtonSetViewDelegate, LoaderManager.LoaderCallbacks<Cursor> {
private lateinit var glide: GlideRequests
private var broadcastReceiver: BroadcastReceiver? = null
@ -74,6 +74,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis
private val publicKey: String
get() = TextSecurePreferences.getLocalNumber(this)!!
private val homeAdapter:HomeAdapter by lazy {
HomeAdapter(this, threadDb.conversationList)
}
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady)
@ -104,8 +108,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis
seedReminderStub.isVisible = false
}
// Set up recycler view
val cursor = threadDb.conversationList
val homeAdapter = HomeAdapter(this, cursor)
homeAdapter.setHasStableIds(true)
homeAdapter.glide = glide
homeAdapter.conversationClickListener = this
@ -115,21 +117,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis
createNewPrivateChatButton.setOnClickListener { createNewPrivateChat() }
IP2Country.configureIfNeeded(this@HomeActivity)
// This is a workaround for the fact that CursorRecyclerViewAdapter doesn't actually auto-update (even though it says it will)
LoaderManager.getInstance(this).restartLoader(0, null, object : LoaderManager.LoaderCallbacks<Cursor> {
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<Cursor> {
return HomeLoader(this@HomeActivity)
}
override fun onLoadFinished(loader: Loader<Cursor>, cursor: Cursor?) {
homeAdapter.changeCursor(cursor)
updateEmptyState()
}
override fun onLoaderReset(cursor: Loader<Cursor>) {
homeAdapter.changeCursor(null)
}
})
LoaderManager.getInstance(this).restartLoader(0, null, this)
// Set up new conversation button set
newConversationButtonSet.delegate = this
// Observe blocked contacts changed events
@ -170,6 +158,19 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis
EventBus.getDefault().register(this@HomeActivity)
}
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<Cursor> {
return HomeLoader(this@HomeActivity)
}
override fun onLoadFinished(loader: Loader<Cursor>, cursor: Cursor?) {
homeAdapter.changeCursor(cursor)
updateEmptyState()
}
override fun onLoaderReset(cursor: Loader<Cursor>) {
homeAdapter.changeCursor(null)
}
override fun onResume() {
super.onResume()
ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(true)
@ -245,7 +246,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis
override fun onLongConversationClick(view: ConversationView) {
val thread = view.thread ?: return
val bottomSheet = ConversationOptionsBottomSheet()
bottomSheet.recipient = thread.recipient
bottomSheet.thread = thread
bottomSheet.onViewDetailsTapped = {
bottomSheet.dismiss()
val userDetailsBottomSheet = UserDetailsBottomSheet()
@ -280,6 +281,18 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis
setNotifyType(thread, notifyType)
}
}
bottomSheet.onPinTapped = {
bottomSheet.dismiss()
if (!thread.isPinned) {
pinConversation(thread)
}
}
bottomSheet.onUnpinTapped = {
bottomSheet.dismiss()
if (thread.isPinned) {
unpinConversation(thread)
}
}
bottomSheet.show(supportFragmentManager, bottomSheet.tag)
}
@ -344,6 +357,24 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis
}
}
private fun pinConversation(thread: ThreadRecord) {
ThreadUtils.queue {
threadDb.setPinned(thread.threadId, true)
Util.runOnMain {
LoaderManager.getInstance(this).restartLoader(0, null, this)
}
}
}
private fun unpinConversation(thread: ThreadRecord) {
ThreadUtils.queue {
threadDb.setPinned(thread.threadId, false)
Util.runOnMain {
LoaderManager.getInstance(this).restartLoader(0, null, this)
}
}
}
private fun deleteConversation(thread: ThreadRecord) {
val threadID = thread.threadId
val recipient = thread.recipient

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple
xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/cell_selected">
<item>
<color android:color="?attr/conversation_pinned_background_color" />
</item>
</ripple>

View File

@ -0,0 +1,11 @@
<!-- drawable/pin_outline.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M16,12V4H17V2H7V4H8V12L6,14V16H11.2V22H12.8V16H18V14L16,12M8.8,14L10,12.8V4H14V12.8L15.2,14H8.8Z" />
</vector>

View File

@ -0,0 +1,11 @@
<!-- drawable/pin_off_outline.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M8,6.2V4H7V2H17V4H16V12L18,14V16H17.8L14,12.2V4H10V8.2L8,6.2M20,20.7L18.7,22L12.8,16.1V22H11.2V16H6V14L8,12V11.3L2,5.3L3.3,4L20,20.7M8.8,14H10.6L9.7,13.1L8.8,14Z" />
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="122.879"
android:viewportHeight="122.867"
android:tint="?attr/conversation_pinned_icon_color">
<path
android:fillColor="@android:color/white"
android:pathData="M83.88,0.451 L122.427,39a1.55,1.55 0,0 1,0 2.188l-13.128,13.125a1.546,1.546 0,0 1,-2.187 0l-3.732,-3.73 -17.303,17.3c3.882,14.621 0.095,30.857 -11.37,42.32 -0.266,0.268 -0.535,0.529 -0.808,0.787 -1.004,0.955 -0.843,0.949 -1.813,-0.021L47.597,86.48 0,122.867l36.399,-47.584L11.874,50.76c-0.978,-0.98 -0.896,-0.826 0.066,-1.837 0.24,-0.251 0.485,-0.503 0.734,-0.753C24.137,36.707 40.376,32.917 54.996,36.8l17.301,-17.3 -3.733,-3.732a1.553,1.553 0,0 1,0 -2.188L81.691,0.451a1.554,1.554 0,0 1,2.189 0z" />
</vector>

View File

@ -16,6 +16,22 @@
android:drawableTint="?attr/colorControlNormal"
android:text="@string/details" />
<TextView
android:id="@+id/pinTextView"
style="@style/BottomSheetActionItem"
android:drawableStart="?attr/menu_pin_icon"
android:text="@string/conversation_pin"
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/unpinTextView"
style="@style/BottomSheetActionItem"
android:drawableStart="?attr/menu_unpin_icon"
android:text="@string/conversation_unpin"
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/blockTextView"
style="@style/BottomSheetActionItem"

View File

@ -5,7 +5,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:background="@drawable/conversation_view_background"
android:gravity="center_vertical"
android:orientation="horizontal">
@ -52,12 +51,14 @@
android:id="@+id/conversationViewDisplayNameTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="4dp"
android:maxLines="1"
android:ellipsize="end"
android:textAlignment="viewStart"
android:textSize="@dimen/medium_font_size"
android:textStyle="bold"
android:textColor="@color/text"
tools:drawableRight="@drawable/ic_pin"
tools:text="I'm a very long display name. What are you going to do about it?" />
<RelativeLayout

View File

@ -31,6 +31,8 @@
<color name="scroll_to_bottom_button_background">#FCFCFC</color>
<color name="scroll_to_bottom_button_border">#99000000</color>
<color name="conversation_unread_count_indicator_background">#E0E0E0</color>
<color name="conversation_pinned_background">#F0F0F0</color>
<color name="conversation_pinned_icon">#606060</color>
<color name="default_background_start">#ffffff</color>
<color name="default_background_end">#fcfcfc</color>

View File

@ -87,6 +87,8 @@
<attr name="conversation_item_audio_seek_bar_color_incoming" format="reference|color" />
<attr name="conversation_item_audio_seek_bar_color_outgoing" format="reference|color" />
<attr name="conversation_item_audio_seek_bar_background_color" format="reference|color" />
<attr name="conversation_pinned_background_color" format="reference|color" />
<attr name="conversation_pinned_icon_color" format="reference|color" />
<attr name="dialog_info_icon" format="reference" />
<attr name="dialog_alert_icon" format="reference" />
@ -117,6 +119,8 @@
<attr name="menu_photo_library_icon" format="reference" />
<attr name="menu_delete_icon" format="reference" />
<attr name="menu_info_icon" format="reference" />
<attr name="menu_pin_icon" format="reference" />
<attr name="menu_unpin_icon" format="reference" />
<attr name="pref_icon_tint" format="color"/>

View File

@ -37,6 +37,8 @@
<color name="scroll_to_bottom_button_background">#171717</color>
<color name="scroll_to_bottom_button_border">#99FFFFFF</color>
<color name="conversation_unread_count_indicator_background">#303030</color>
<color name="conversation_pinned_background">#404040</color>
<color name="conversation_pinned_icon">#B3B3B3</color>
<array name="profile_picture_placeholder_colors">
<item>#5ff8b0</item>

View File

@ -902,5 +902,7 @@
<string name="activity_settings_support">Debug Log</string>
<string name="dialog_share_logs_title">Share Logs</string>
<string name="dialog_share_logs_explanation">Would you like to export your application logs to be able to share for troubleshooting?</string>
<string name="conversation_pin">Pin</string>
<string name="conversation_unpin">Unpin</string>
</resources>

View File

@ -76,6 +76,8 @@
<item name="menu_split_icon">@drawable/ic_baseline_call_split_24</item>
<item name="menu_popup_expand">@drawable/ic_baseline_launch_24</item>
<item name="menu_info_icon">@drawable/ic_baseline_info_24</item>
<item name="menu_pin_icon">@drawable/ic_outline_pin_24</item>
<item name="menu_unpin_icon">@drawable/ic_outline_pin_off_24</item>
<item name="conversation_emoji_toggle">@drawable/ic_emoji_filled_keyboard_24</item>
<item name="conversation_sticker_toggle">@drawable/ic_sticker_filled_keyboard_24</item>
@ -88,6 +90,9 @@
<item name="conversation_item_audio_seek_bar_color_incoming">@color/accent</item>
<item name="conversation_item_audio_seek_bar_color_outgoing">@color/accent</item>
<item name="conversation_item_audio_seek_bar_background_color">@color/text</item>
<item name="conversation_pinned_background_color">@color/conversation_pinned_background</item>
<item name="conversation_pinned_icon_color">@color/conversation_pinned_icon</item>
</style>
<!-- This should be the default theme for the application. -->

View File

@ -87,6 +87,8 @@
<attr name="conversation_item_audio_seek_bar_color_incoming" format="reference|color" />
<attr name="conversation_item_audio_seek_bar_color_outgoing" format="reference|color" />
<attr name="conversation_item_audio_seek_bar_background_color" format="reference|color" />
<attr name="conversation_pinned_background_color" format="reference|color" />
<attr name="conversation_pinned_icon_color" format="reference|color" />
<attr name="dialog_info_icon" format="reference" />
<attr name="dialog_alert_icon" format="reference" />
@ -117,6 +119,8 @@
<attr name="menu_photo_library_icon" format="reference" />
<attr name="menu_delete_icon" format="reference" />
<attr name="menu_info_icon" format="reference" />
<attr name="menu_pin_icon" format="reference" />
<attr name="menu_unpin_icon" format="reference" />
<attr name="pref_icon_tint" format="color"/>