Merge branch 'hashtags' into 'master'

Implement viewing hashtags

See merge request pixeldroid/PixelDroid!358
This commit is contained in:
Matthieu 2021-06-07 20:23:59 +00:00
commit 2ab6651be0
24 changed files with 278 additions and 71 deletions

View File

@ -111,7 +111,7 @@ dependencies {
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
implementation "androidx.browser:browser:1.3.0"
implementation 'androidx.recyclerview:recyclerview:1.2.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
@ -133,7 +133,7 @@ dependencies {
implementation "androidx.camera:camera-lifecycle:$cameraX_version"
// CameraX View class
implementation 'androidx.camera:camera-view:1.0.0-alpha24'
implementation 'androidx.camera:camera-view:1.0.0-alpha25'
def room_version = "2.3.0"
implementation "androidx.room:room-runtime:$room_version"

View File

@ -6,6 +6,7 @@ import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.openLinkWithText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition
import androidx.test.espresso.contrib.RecyclerViewActions.scrollToPosition
@ -13,11 +14,10 @@ import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayout
import org.hamcrest.CoreMatchers.*
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.posts.StatusViewHolder
import org.pixeldroid.app.testUtility.*
import org.hamcrest.CoreMatchers.not
import org.hamcrest.CoreMatchers.sameInstance
import org.hamcrest.core.IsInstanceOf
import org.hamcrest.core.StringContains.containsString
import org.junit.After
@ -40,8 +40,6 @@ class HomeFeedTest {
@Rule @JvmField
var repeatRule: RepeatRule = RepeatRule()
@get:Rule
var globalTimeout: Timeout = Timeout.seconds(100)
@Before
fun before(){
@ -94,6 +92,26 @@ class HomeFeedTest {
onView(first(withId(R.id.description))).check(matches(withText(containsString("@user2"))));
}
@Test
@RepeatTest
fun hashtag() {
//Wait for the feed to load
waitForView(R.id.postPager)
onView(allOf(withClassName(endsWith("RecyclerView")), not(withId(R.id.material_drawer_recycler_view))))
.perform(
scrollToPosition<StatusViewHolder>(3)
)
onView(allOf(withText(containsString("randomNoise"))))
.perform(clickClickableSpan("#randomNoise"))
waitForView(R.id.action_bar, allOf(withText("#randomNoise"), not(withId(R.id.description))))
onView(withId(R.id.action_bar)).check(matches(isDisplayed()));
onView(allOf(withText("#randomNoise"), not(withId(R.id.description)))).check(matches(withParent(withId(R.id.action_bar))));
}
/*
@Test
fun clickingReblogButtonWorks() {
@ -187,13 +205,6 @@ class HomeFeedTest {
onView(first(withId(R.id.username))).check(matches(isDisplayed()))
}
/*
@Test
fun clickingHashTagsWorks() {
onView(withId(R.id.list)).perform(
actionOnItemAtPosition<StatusViewHolder>(1, clickChildViewWithId(R.id.description))
)
onView(withId(R.id.list)).check(matches(isDisplayed()))
}
@Test

View File

@ -8,6 +8,7 @@ import android.widget.TextView
import androidx.appcompat.widget.SearchView
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.*
import androidx.test.espresso.NoMatchingViewException
import androidx.test.espresso.action.*
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.BoundedMatcher
@ -15,7 +16,6 @@ import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.espresso.util.HumanReadables
import androidx.test.espresso.util.TreeIterables
import org.pixeldroid.app.R
import org.hamcrest.BaseMatcher
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.Description
@ -23,6 +23,7 @@ import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.junit.rules.TestRule
import org.junit.runners.model.Statement
import org.pixeldroid.app.R
import java.util.concurrent.TimeoutException
@ -298,6 +299,54 @@ fun clickChildViewWithId(id: Int) = object : ViewAction {
}
}
fun clickClickableSpan(textToClick: CharSequence): ViewAction? {
return object : ViewAction {
override fun getConstraints(): Matcher<View> {
return Matchers.instanceOf(TextView::class.java)
}
override fun getDescription(): String {
return "clicking on a ClickableSpan"
}
override fun perform(uiController: UiController?, view: View) {
val textView = view as TextView
val spannableString = textView.text as SpannableString
if (spannableString.isEmpty()) {
// TextView is empty, nothing to do
throw NoMatchingViewException.Builder()
.includeViewHierarchy(true)
.withRootView(textView)
.build()
}
// Get the links inside the TextView and check if we find textToClick
val spans = spannableString.getSpans(
0, spannableString.length,
ClickableSpan::class.java
)
if (spans.isNotEmpty()) {
var spanCandidate: ClickableSpan?
for (span in spans) {
spanCandidate = span
val start = spannableString.getSpanStart(spanCandidate)
val end = spannableString.getSpanEnd(spanCandidate)
val sequence = spannableString.subSequence(start, end)
if (textToClick.toString() == sequence.toString()) {
span.onClick(textView)
return
}
}
}
throw NoMatchingViewException.Builder()
.includeViewHierarchy(true)
.withRootView(textView)
.build()
}
}
}
fun typeTextInViewWithId(id: Int, text: String) = object : ViewAction {
override fun getConstraints() = null

View File

@ -49,6 +49,10 @@
android:name=".profile.FollowsActivity"
android:screenOrientation="sensorPortrait"
tools:ignore="LockedOrientationActivity" />
<activity
android:name=".posts.feeds.uncachedFeeds.hashtags.HashTagActivity"
android:screenOrientation="sensorPortrait"
tools:ignore="LockedOrientationActivity" />
<activity
android:name=".posts.PostActivity"
android:screenOrientation="sensorPortrait"

View File

@ -20,9 +20,4 @@ class CameraActivity : BaseActivity() {
supportFragmentManager.beginTransaction()
.add(R.id.camera_activity_fragment, cameraFragment).commit()
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
}

View File

@ -17,6 +17,7 @@ import org.pixeldroid.app.R
import org.pixeldroid.app.utils.api.PixelfedAPI
import org.pixeldroid.app.utils.api.objects.Account.Companion.openAccountFromId
import org.pixeldroid.app.utils.api.objects.Mention
import org.pixeldroid.app.utils.api.objects.Tag.Companion.openTag
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import java.net.URI
import java.net.URISyntaxException
@ -74,7 +75,7 @@ fun parseHTMLText(
val tag = text.subSequence(1, text.length).toString()
customSpan = object : ClickableSpanNoUnderline() {
override fun onClick(widget: View) {
Toast.makeText(context, tag, Toast.LENGTH_SHORT).show()
openTag(context, tag)
}
}

View File

@ -72,11 +72,6 @@ class PostActivity : BaseActivity() {
}
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
private fun activateCommenter() {
//Activate commenter
binding.submitComment.setOnClickListener {

View File

@ -66,9 +66,4 @@ class ReportActivity : BaseActivity() {
binding.reportProgressBar.visibility = View.GONE
}
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
}

View File

@ -1,4 +1,4 @@
package org.pixeldroid.app.posts.feeds.uncachedFeeds.search
package org.pixeldroid.app.posts.feeds.uncachedFeeds
import android.os.Bundle
import android.view.LayoutInflater
@ -12,24 +12,31 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.pixeldroid.app.R
import org.pixeldroid.app.posts.StatusViewHolder
import org.pixeldroid.app.posts.feeds.uncachedFeeds.*
import org.pixeldroid.app.posts.feeds.uncachedFeeds.hashtags.HashTagContentRepository
import org.pixeldroid.app.posts.feeds.uncachedFeeds.search.SearchContentRepository
import org.pixeldroid.app.utils.api.objects.Results
import org.pixeldroid.app.utils.api.objects.Status
import org.pixeldroid.app.utils.api.objects.Tag.Companion.HASHTAG_TAG
import org.pixeldroid.app.utils.displayDimensionsInPx
/**
* Fragment to show a list of [Status]es, as a result of a search.
* Fragment to show a list of [Status]es, as a result of a search or a hashtag.
*/
class SearchPostsFragment : UncachedFeedFragment<Status>() {
class UncachedPostsFragment : UncachedFeedFragment<Status>() {
private lateinit var query: String
private var hashtagOrQuery: String? = null
private var search: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = PostsAdapter(requireContext().displayDimensionsInPx())
query = arguments?.getSerializable("searchFeed") as String
hashtagOrQuery = arguments?.getString(HASHTAG_TAG)
if(hashtagOrQuery == null){
search = true
hashtagOrQuery = arguments?.getString("searchFeed")!!
}
}
@ExperimentalPagingApi
@ -41,15 +48,27 @@ class SearchPostsFragment : UncachedFeedFragment<Status>() {
// get the view model
@Suppress("UNCHECKED_CAST")
viewModel = ViewModelProvider(requireActivity(), ViewModelFactory(
SearchContentRepository<Status>(
apiHolder.setToCurrentUser(),
Results.SearchType.statuses,
query
viewModel = if(search) {
ViewModelProvider(
requireActivity(), ViewModelFactory(
SearchContentRepository<Status>(
apiHolder.setToCurrentUser(),
Results.SearchType.statuses,
hashtagOrQuery!!
)
)
)
)
)
.get("searchPosts", FeedViewModel::class.java) as FeedViewModel<Status>
.get("searchPosts", FeedViewModel::class.java) as FeedViewModel<Status>
} else {
ViewModelProvider(requireActivity(), ViewModelFactory(
HashTagContentRepository(
apiHolder.setToCurrentUser(),
hashtagOrQuery!!
)
)
)
.get(HASHTAG_TAG, FeedViewModel::class.java) as FeedViewModel<Status>
}
launch()
initSearch()

View File

@ -0,0 +1,35 @@
package org.pixeldroid.app.posts.feeds.uncachedFeeds.hashtags
import android.os.Bundle
import org.pixeldroid.app.R
import org.pixeldroid.app.posts.feeds.uncachedFeeds.UncachedPostsFragment
import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.api.objects.Tag.Companion.HASHTAG_TAG
class HashTagActivity : BaseActivity() {
private var tagFragment = UncachedPostsFragment()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_followers)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
// Get hashtag tag
val tag = intent.getSerializableExtra(HASHTAG_TAG) as String?
startFragment(tag!!)
}
private fun startFragment(tag : String) {
supportActionBar?.title = getString(R.string.hashtag_title).format(tag)
val arguments = Bundle()
arguments.putSerializable(HASHTAG_TAG, tag)
tagFragment.arguments = arguments
supportFragmentManager.beginTransaction()
.add(R.id.followsFragment, tagFragment).commit()
}
}

View File

@ -0,0 +1,38 @@
package org.pixeldroid.app.posts.feeds.uncachedFeeds.hashtags
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import org.pixeldroid.app.utils.api.PixelfedAPI
import org.pixeldroid.app.posts.feeds.uncachedFeeds.UncachedContentRepository
import org.pixeldroid.app.utils.api.objects.FeedContent
import org.pixeldroid.app.utils.api.objects.Results
import kotlinx.coroutines.flow.Flow
import org.pixeldroid.app.utils.api.objects.Status
import javax.inject.Inject
/**
* Repository class for viewing hashtags
*/
class HashTagContentRepository @ExperimentalPagingApi
@Inject constructor(
private val api: PixelfedAPI,
private val hashtag: String,
): UncachedContentRepository<Status> {
override fun getStream(): Flow<PagingData<Status>> {
return Pager(
config = PagingConfig(
initialLoadSize = NETWORK_PAGE_SIZE,
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false),
pagingSourceFactory = {
HashTagPagingSource(api, hashtag)
}
).flow
}
companion object {
private const val NETWORK_PAGE_SIZE = 20
}
}

View File

@ -0,0 +1,45 @@
package org.pixeldroid.app.posts.feeds.uncachedFeeds.hashtags
import androidx.paging.PagingSource
import androidx.paging.PagingState
import org.pixeldroid.app.utils.api.PixelfedAPI
import org.pixeldroid.app.utils.api.objects.FeedContent
import org.pixeldroid.app.utils.api.objects.Results
import org.pixeldroid.app.utils.api.objects.Status
import retrofit2.HttpException
import java.io.IOException
/**
* Provides the PagingSource for hashtag feeds. Is used in [HashTagContentRepository]
*/
class HashTagPagingSource(
private val api: PixelfedAPI,
private val query: String,
) : PagingSource<String, Status>() {
override suspend fun load(params: LoadParams<String>): LoadResult<String, Status> {
val position = params.key
return try {
val response = api.hashtag(
hashtag = query,
limit = params.loadSize,
max_id = position,
)
LoadResult.Page(
data = response,
prevKey = null,
nextKey = response.lastOrNull()?.id
)
} catch (exception: HttpException) {
LoadResult.Error(exception)
} catch (exception: IOException) {
LoadResult.Error(exception)
}
}
/**
* FIXME if implemented with [PagingState.anchorPosition], this breaks refreshes? How is this
* supposed to work?
*/
override fun getRefreshKey(state: PagingState<String, Status>): String? = null
}

View File

@ -18,6 +18,7 @@ import org.pixeldroid.app.posts.feeds.uncachedFeeds.UncachedFeedFragment
import org.pixeldroid.app.posts.feeds.uncachedFeeds.ViewModelFactory
import org.pixeldroid.app.utils.api.objects.Results
import org.pixeldroid.app.utils.api.objects.Tag
import org.pixeldroid.app.utils.api.objects.Tag.Companion.openTag
/**
* Fragment to show a list of [hashtag][Tag]s, as a result of a search.
@ -86,7 +87,7 @@ class HashTagAdapter : PagingDataAdapter<Tag, RecyclerView.ViewHolder>(
companion object {
private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback<Tag>() {
override fun areItemsTheSame(oldItem: Tag, newItem: Tag): Boolean {
return oldItem.id == newItem.id
return oldItem.name == newItem.name
}
override fun areContentsTheSame(oldItem: Tag, newItem: Tag): Boolean =
@ -107,7 +108,9 @@ class HashTagViewHolder(binding: FragmentTagsBinding) : RecyclerView.ViewHolder(
init {
itemView.setOnClickListener {
//TODO
tag?.apply {
openTag(itemView.context, this.name)
}
}
}

View File

@ -31,11 +31,6 @@ class FollowsActivity : BaseActivity() {
}
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
private fun startFragment(id : String, displayName: String, followers : Boolean) {
supportActionBar?.title =
if (followers) {

View File

@ -107,11 +107,6 @@ class ProfileActivity : BaseActivity() {
binding.profileRefreshLayout.isRefreshing = false
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
private fun setContent(account: Account?) {
if(account != null) {
setViews(account)

View File

@ -9,9 +9,9 @@ import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.pixeldroid.app.R
import org.pixeldroid.app.posts.feeds.uncachedFeeds.UncachedPostsFragment
import org.pixeldroid.app.posts.feeds.uncachedFeeds.search.SearchAccountFragment
import org.pixeldroid.app.posts.feeds.uncachedFeeds.search.SearchHashtagFragment
import org.pixeldroid.app.posts.feeds.uncachedFeeds.search.SearchPostsFragment
import org.pixeldroid.app.utils.api.objects.Results
import org.pixeldroid.app.utils.BaseActivity
@ -47,14 +47,9 @@ class SearchActivity : BaseActivity() {
setupTabs(tabs, searchType)
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
private fun createSearchTabs(query: String): Array<Fragment>{
val searchFeedFragment = SearchPostsFragment()
val searchFeedFragment = UncachedPostsFragment()
val searchAccountListFragment =
SearchAccountFragment()
val searchHashtagFragment: Fragment = SearchHashtagFragment()

View File

@ -28,6 +28,11 @@ open class BaseActivity : AppCompatActivity() {
super.attachBaseContext(updateBaseContextLocale(base))
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
private fun updateBaseContextLocale(context: Context): Context {
val language = PreferenceManager.getDefaultSharedPreferences(context).getString("language", "default") ?: "default"
if(language == "default"){

View File

@ -153,6 +153,17 @@ interface PixelfedAPI {
@Query("local") local: Boolean? = null
): List<Status>
@GET("/api/v1/timelines/tag/{hashtag}")
suspend fun hashtag(
@Path("hashtag") hashtag: String? = null,
@Query("local") local: Boolean? = null,
@Query("only_media") only_media: Boolean? = null,
@Query("max_id") max_id: String? = null,
@Query("since_id") since_id: String? = null,
@Query("min_id") min_id: String? = null,
@Query("limit") limit: Int? = null,
): List<Status>
@GET("/api/v2/search")
suspend fun search(
@Query("account_id") account_id: String? = null,

View File

@ -1,5 +1,7 @@
package org.pixeldroid.app.utils.api.objects
import java.io.Serializable
data class Error(
val error: String?
)
): Serializable

View File

@ -1,8 +1,10 @@
package org.pixeldroid.app.utils.api.objects
import java.io.Serializable
data class History(
//Required attributes
val day: String,
val uses: String,
val accounts: String
)
): Serializable

View File

@ -1,6 +1,7 @@
package org.pixeldroid.app.utils.api.objects
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity.Companion.DEFAULT_MAX_TOOT_CHARS
import java.io.Serializable
data class Instance (
val description: String?,
@ -11,4 +12,4 @@ data class Instance (
val title: String?,
val uri: String?,
val version: String?
)
): Serializable

View File

@ -1,5 +1,9 @@
package org.pixeldroid.app.utils.api.objects
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import org.pixeldroid.app.posts.feeds.uncachedFeeds.hashtags.HashTagActivity
import java.io.Serializable
data class Tag(
@ -11,5 +15,15 @@ data class Tag(
//needed to be a FeedContent, this inheritance is a bit fickle. Do not use.
override val id: String
get() = "tag"
companion object {
const val HASHTAG_TAG = "HashtagTag"
fun openTag(context: Context, tag: String) {
val intent = Intent(context, HashTagActivity::class.java)
intent.putExtra(HASHTAG_TAG, tag)
ContextCompat.startActivity(context, intent, null)
}
}
}

View File

@ -1,9 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/followsFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".profile.FollowsActivity">
</androidx.constraintlayout.widget.ConstraintLayout>
android:layout_height="match_parent"/>

View File

@ -200,6 +200,7 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"</string>
<string name="about">About</string>
<string name="post_title">%1$s\'s post</string>
<string name="followers_title">%1$s\'s followers</string>
<string name="hashtag_title">#%1$s</string>
<string name="follows_title">%1$s\'s follows</string>
<string name="search_empty_error">Search query can\'t be empty</string>
<string name="status_more_options">More options</string>