This commit is contained in:
MarieJ 2024-01-18 18:32:47 +01:00
parent 0fe2c54940
commit 7f3ab1e4a6
9 changed files with 472 additions and 10 deletions

View File

@ -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<Conversation>() {
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<Conversation>
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<String, Drawable?> =
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<Conversation, RecyclerView.ViewHolder>(
object : DiffUtil.ItemCallback<Conversation>() {
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
)
}
}
}
}

View File

@ -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<Int, Conversation>() {
override suspend fun load(loadType: LoadType, state: PagingState<Int, Conversation>): 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)
}
}
}

View File

@ -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<Account>? = 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?
}
}

View File

@ -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) {

View File

@ -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<Conversation> {
@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<Int, Conversation>
}

View File

@ -13,7 +13,4 @@ interface FeedContentDao<T: FeedContentDatabase>{
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(feedContent: List<T>)
suspend fun delete(id: String, userId: String, instanceUri: String)
}

View File

@ -18,7 +18,4 @@ interface NotificationDao: FeedContentDao<Notification> {
@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)
}

View File

@ -16,7 +16,7 @@ interface HomePostDao: FeedContentDao<HomeStatusDatabaseEntity> {
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)

View File

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:layout_margin="5dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp">
<TextView
android:id="@+id/message_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="@+id/notification_type"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="July 23" />
<TextView
android:id="@+id/notification_type"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:drawablePadding="6dp"
android:paddingStart="38dp"
android:textColor="?android:textColorTertiary"
android:textStyle="bold"
app:drawableStartCompat="@drawable/ic_heart"
app:layout_constraintEnd_toStartOf="@+id/notification_time"
app:layout_constraintStart_toStartOf="@+id/notification_avatar"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlSymmetry"
tools:text="fdsqfdsfsqdfdsfqdsfsdfsfddsfqsdsdfsqdf liked your post" />
<ImageView
android:id="@+id/notification_avatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="14dp"
android:layout_marginTop="14dp"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/notification_type"
tools:src="@drawable/ic_default_user"
android:contentDescription="@string/profile_picture" />
<ImageView
android:id="@+id/notification_photo_thumbnail"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="14dp"
android:scaleType="centerCrop"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/notification_type"
tools:src="@drawable/ic_default_user"
tools:srcCompat="@tools:sample/backgrounds/scenic"
android:contentDescription="@string/notification_thumbnail" />
<TextView
android:id="@+id/notification_post_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/notification_photo_thumbnail"
app:layout_constraintHorizontal_bias="0.164"
app:layout_constraintStart_toEndOf="@+id/notification_avatar"
app:layout_constraintTop_toBottomOf="@+id/notification_type"
app:layout_constraintVertical_bias="0.408"
tools:text="Post description" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>