diff --git a/app/src/main/java/org/pixeldroid/app/posts/feeds/cachedFeeds/directMessages/DirectMessagesFragment.kt b/app/src/main/java/org/pixeldroid/app/posts/feeds/cachedFeeds/directMessages/DirectMessagesFragment.kt new file mode 100644 index 00000000..cde17993 --- /dev/null +++ b/app/src/main/java/org/pixeldroid/app/posts/feeds/cachedFeeds/directMessages/DirectMessagesFragment.kt @@ -0,0 +1,262 @@ +package org.pixeldroid.app.posts.feeds.cachedFeeds.directMessages + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.paging.ExperimentalPagingApi +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import org.pixeldroid.app.R +import org.pixeldroid.app.databinding.FragmentConversationsBinding +import org.pixeldroid.app.databinding.FragmentNotificationsBinding +import org.pixeldroid.app.posts.PostActivity +import org.pixeldroid.app.posts.feeds.cachedFeeds.CachedFeedFragment +import org.pixeldroid.app.posts.feeds.cachedFeeds.FeedViewModel +import org.pixeldroid.app.posts.feeds.cachedFeeds.ViewModelFactory +import org.pixeldroid.app.posts.parseHTMLText +import org.pixeldroid.app.posts.setTextViewFromISO8601 +import org.pixeldroid.app.profile.ProfileActivity +import org.pixeldroid.app.utils.api.objects.Account +import org.pixeldroid.app.utils.api.objects.Conversation +import org.pixeldroid.app.utils.api.objects.Notification +import org.pixeldroid.app.utils.api.objects.Status +import org.pixeldroid.app.utils.di.PixelfedAPIHolder +import org.pixeldroid.app.utils.notificationsWorker.makeChannelGroupId + + +/** + * Fragment for the notifications tab. + */ +class DirectMessagesFragment : CachedFeedFragment() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + adapter = DirectMessagesAdapter(apiHolder) + } + + @OptIn(ExperimentalPagingApi::class) + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = super.onCreateView(inflater, container, savedInstanceState) + + // get the view model + @Suppress("UNCHECKED_CAST") + viewModel = ViewModelProvider( + requireActivity(), + ViewModelFactory(db, db.directMessagesDao(), DirectMessagesRemoteMediator(apiHolder, db)) + )["conversations", FeedViewModel::class.java] as FeedViewModel + + launch() + initSearch() + + return view + } + + + override fun onResume() { + super.onResume() + with(NotificationManagerCompat.from(requireContext())) { + // Cancel account notification group + db.userDao().getActiveUser()?.let { + cancel( makeChannelGroupId(it).hashCode()) + } + } + } + + /** + * View Holder for a [Conversation] RecyclerView list item. + */ + class ConversationViewHolder(binding: FragmentConversationsBinding) : RecyclerView.ViewHolder(binding.root) { + private val messageTime: TextView = binding.messageTime + private val avatar: ImageView = binding.notificationAvatar + private val photoThumbnail: ImageView = binding.notificationPhotoThumbnail + + private var conversation: Conversation? = null + + init { + itemView.setOnClickListener { + conversation?.openActivity() + } + avatar.setOnClickListener { + val intent = conversation?.openAccountFromNotification() + itemView.context.startActivity(intent) + } + } + + private fun Conversation.openActivity() { + val intent: Intent = openConversation() + itemView.context.startActivity(intent) + } + + private fun Notification.openConversation(): Intent = + Intent(itemView.context, PostActivity::class.java).apply { + putExtra(Status.POST_TAG, status) + } + + private fun setNotificationType( + type: Notification.NotificationType, + username: String, + textView: TextView + ) { + val context = textView.context + val (format: String, drawable: Drawable?) = when (type) { + Notification.NotificationType.follow -> + getStringAndDrawable( + context, + R.string.followed_notification, + R.drawable.ic_follow + ) + Notification.NotificationType.mention -> + getStringAndDrawable( + context, + R.string.mention_notification, + R.drawable.mention_at_24dp + ) + Notification.NotificationType.comment -> + getStringAndDrawable( + context, + R.string.comment_notification, + R.drawable.ic_comment_empty + ) + Notification.NotificationType.reblog -> + getStringAndDrawable( + context, + R.string.shared_notification, + R.drawable.ic_reblog_blue + ) + Notification.NotificationType.favourite -> + getStringAndDrawable( + context, + R.string.liked_notification, + R.drawable.ic_like_full + ) + Notification.NotificationType.poll -> + getStringAndDrawable(context, R.string.poll_notification, R.drawable.poll) + Notification.NotificationType.follow_request -> getStringAndDrawable( + context, + R.string.follow_request, + R.drawable.ic_follow + ) + Notification.NotificationType.status -> getStringAndDrawable( + context, + R.string.status_notification, + R.drawable.ic_comment_empty + ) + } + textView.text = format.format(username) + textView.setCompoundDrawablesWithIntrinsicBounds( + drawable, null, null, null + ) + } + + private fun getStringAndDrawable( + context: Context, + stringToFormat: Int, + drawable: Int + ): Pair = + Pair(context.getString(stringToFormat), ContextCompat.getDrawable(context, drawable)) + + + fun bind( + conversation: Conversation?, + api: PixelfedAPIHolder, + lifecycleScope: LifecycleCoroutineScope, + ) { + + this.conversation = conversation + + Glide.with(itemView).load(conversation?.accounts?.first()?.anyAvatar()).circleCrop() + .into(avatar) + + val previewUrl = conversation?.accounts?.first()?.anyAvatar() + if (!previewUrl.isNullOrBlank()) { + Glide.with(itemView).load(previewUrl) + .placeholder(R.drawable.ic_picture_fallback).into(photoThumbnail) + } else { + photoThumbnail.visibility = View.GONE + } + + conversation?.last_status?.created_at?.let { + setTextViewFromISO8601( + it, + notificationTime, + false + ) + } + + // Convert HTML to clickable text + postDescription.text = + parseHTMLText( + notification?.status?.content ?: "", + notification?.status?.mentions, + api, + itemView.context, + lifecycleScope + ) + } + + companion object { + fun create(parent: ViewGroup): ConversationViewHolder { + val itemBinding = FragmentNotificationsBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + return ConversationViewHolder(itemBinding) + } + } + } + + + inner class DirectMessagesAdapter( + private val apiHolder: PixelfedAPIHolder, + ) : PagingDataAdapter( + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: Conversation, + newItem: Conversation + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: Conversation, + newItem: Conversation + ): Boolean = + oldItem == newItem + } + ) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return ConversationViewHolder.create(parent) + } + + override fun getItemViewType(position: Int): Int { + return R.layout.fragment_notifications + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val uiModel = getItem(position) + uiModel?.let { + (holder as ConversationViewHolder).bind( + it, + apiHolder, + lifecycleScope + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/pixeldroid/app/posts/feeds/cachedFeeds/directMessages/DirectMessagesRemoteMediator.kt b/app/src/main/java/org/pixeldroid/app/posts/feeds/cachedFeeds/directMessages/DirectMessagesRemoteMediator.kt new file mode 100644 index 00000000..962f497a --- /dev/null +++ b/app/src/main/java/org/pixeldroid/app/posts/feeds/cachedFeeds/directMessages/DirectMessagesRemoteMediator.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.pixeldroid.app.posts.feeds.cachedFeeds.directMessages + +import androidx.paging.* +import androidx.room.withTransaction +import org.pixeldroid.app.utils.api.objects.Conversation +import org.pixeldroid.app.utils.db.AppDatabase +import org.pixeldroid.app.utils.di.PixelfedAPIHolder +import java.lang.Exception +import java.lang.NullPointerException +import javax.inject.Inject + +/** + * RemoteMediator for the notifications. + * + * A [RemoteMediator] defines a set of callbacks used to incrementally load data from a remote + * source into a local source wrapped by a [PagingSource], e.g., loading data from network into + * a local db cache. + */ +@OptIn(ExperimentalPagingApi::class) +class DirectMessagesRemoteMediator @Inject constructor( + private val apiHolder: PixelfedAPIHolder, + private val db: AppDatabase +) : RemoteMediator() { + + override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { + + val maxId = when (loadType) { + LoadType.REFRESH -> null + LoadType.PREPEND -> { + // No prepend for the moment, might be nice to add later + return MediatorResult.Success(endOfPaginationReached = true) + } + LoadType.APPEND -> state.lastItemOrNull()?.id + ?: return MediatorResult.Success(endOfPaginationReached = true) + } + + try { + val user = db.userDao().getActiveUser() + ?: return MediatorResult.Error(NullPointerException("No active user exists")) + val api = apiHolder.api ?: apiHolder.setToCurrentUser() + + val apiResponse = api.viewAllConversations( + max_id = maxId, + limit = state.config.pageSize.toString() + ) + + apiResponse.forEach{it.user_id = user.user_id; it.instance_uri = user.instance_uri} + + val endOfPaginationReached = apiResponse.isEmpty() + + db.withTransaction { + // Clear table in the database + if (loadType == LoadType.REFRESH) { + db.directMessagesDao().clearFeedContent(user.user_id, user.instance_uri) + } + db.directMessagesDao().insertAll(apiResponse) + } + return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached) + } catch (exception: Exception){ + return MediatorResult.Error(exception) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/pixeldroid/app/utils/api/objects/Conversation.kt b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Conversation.kt index 09a3c3e9..c52c0685 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/api/objects/Conversation.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Conversation.kt @@ -1,5 +1,9 @@ package org.pixeldroid.app.utils.api.objects +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity import java.io.Serializable /* @@ -7,10 +11,31 @@ Represents a conversation. https://docs.joinmastodon.org/entities/Conversation/ */ +@Entity( + tableName = "direct_messages", + primaryKeys = ["id", "user_id", "instance_uri"], + foreignKeys = [ForeignKey( + entity = UserDatabaseEntity::class, + parentColumns = arrayOf("user_id", "instance_uri"), + childColumns = arrayOf("user_id", "instance_uri"), + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + )], + indices = [Index(value = ["user_id", "instance_uri"])] +) data class Conversation( //Base attributes override val id: String?, val unread: Boolean? = true, val accounts: List? = null, - val last_statuses: Status? = null -) : Serializable, FeedContent + val last_status: Status? = null, + + //Database values (not from API) + //TODO do we find this approach acceptable? Preferable to a semi-duplicate NotificationDataBaseEntity? + override var user_id: String, + override var instance_uri: String, + ): FeedContent, FeedContentDatabase, Serializable { + enum class NotificationType : Serializable { + follow, follow_request, mention, reblog, favourite, poll, status, comment //comment is Pixelfed-specific? + } +} \ No newline at end of file diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/AppDatabase.kt b/app/src/main/java/org/pixeldroid/app/utils/db/AppDatabase.kt index 0b76b6ef..164782f8 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/db/AppDatabase.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/db/AppDatabase.kt @@ -14,13 +14,16 @@ import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity import org.pixeldroid.app.utils.db.entities.PublicFeedStatusDatabaseEntity import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity import org.pixeldroid.app.utils.api.objects.Notification +import org.pixeldroid.app.utils.api.objects.Conversation +import org.pixeldroid.app.utils.db.dao.feedContent.DirectMessagesDao @Database(entities = [ InstanceDatabaseEntity::class, UserDatabaseEntity::class, HomeStatusDatabaseEntity::class, PublicFeedStatusDatabaseEntity::class, - Notification::class + Notification::class, + Conversation::class ], version = 5 ) @@ -31,6 +34,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun homePostDao(): HomePostDao abstract fun publicPostDao(): PublicPostDao abstract fun notificationDao(): NotificationDao + abstract fun directMessagesDao(): DirectMessagesDao } val MIGRATION_3_4 = object : Migration(3, 4) { diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/DirectMessagesDao.kt b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/DirectMessagesDao.kt new file mode 100644 index 00000000..14749399 --- /dev/null +++ b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/DirectMessagesDao.kt @@ -0,0 +1,19 @@ +package org.pixeldroid.app.utils.db.dao.feedContent + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Query +import org.pixeldroid.app.utils.api.objects.Conversation +import org.pixeldroid.app.utils.api.objects.Notification +import org.pixeldroid.app.utils.db.dao.feedContent.FeedContentDao + +@Dao +interface DirectMessagesDao: FeedContentDao { + + @Query("DELETE FROM direct_messages WHERE user_id=:userId AND instance_uri=:instanceUri") + override suspend fun clearFeedContent(userId: String, instanceUri: String) + + // TODO: might have to order by date or some other value + @Query("""SELECT * FROM direct_messages WHERE user_id=:userId AND instance_uri=:instanceUri """) + override fun feedContent(userId: String, instanceUri: String): PagingSource +} \ No newline at end of file diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/FeedContentDao.kt b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/FeedContentDao.kt index 31ef8e7d..b7e23dc2 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/FeedContentDao.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/FeedContentDao.kt @@ -13,7 +13,4 @@ interface FeedContentDao{ @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(feedContent: List) - - suspend fun delete(id: String, userId: String, instanceUri: String) - } \ No newline at end of file diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/NotificationDao.kt b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/NotificationDao.kt index 0c498190..72b711fb 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/NotificationDao.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/NotificationDao.kt @@ -18,7 +18,4 @@ interface NotificationDao: FeedContentDao { @Query("""SELECT * FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri ORDER BY datetime(created_at) DESC LIMIT 1""") fun latestNotification(userId: String, instanceUri: String): Notification? - - @Query("DELETE FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id") - override suspend fun delete(id: String, userId: String, instanceUri: String) } \ No newline at end of file diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/HomePostDao.kt b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/HomePostDao.kt index 07efde38..0eb23f49 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/HomePostDao.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/HomePostDao.kt @@ -16,7 +16,7 @@ interface HomePostDao: FeedContentDao { override suspend fun clearFeedContent(userId: String, instanceUri: String) @Query("DELETE FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id") - override suspend fun delete(id: String, userId: String, instanceUri: String) + suspend fun delete(id: String, userId: String, instanceUri: String) @Query("UPDATE homePosts SET bookmarked=:bookmarked WHERE user_id=:id AND instance_uri=:instanceUri AND id=:statusId") fun bookmarkStatus(id: String, instanceUri: String, statusId: String, bookmarked: Boolean) diff --git a/app/src/main/res/layout/fragment_conversations.xml b/app/src/main/res/layout/fragment_conversations.xml new file mode 100644 index 00000000..4a1aa1aa --- /dev/null +++ b/app/src/main/res/layout/fragment_conversations.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + +