From fef4b8b07f6453b1c39715e23d23dc93b1bccb21 Mon Sep 17 00:00:00 2001 From: kyori19 Date: Thu, 19 Nov 2020 05:12:27 +0900 Subject: [PATCH] [needs help] Support announcements (#1977) * Implement announcements activity * Update reactions without api access * Add badge style * Use emptyList() as default parameter * Simplify newIntent * Use List instead of Array * Remove unneeded ConstraintLayout * Add lineSpacingMultiplier * Fix wording * Apply material design's default chip style * Dismiss announcements automatically --- app/src/main/AndroidManifest.xml | 9 +- .../com/keylesspalace/tusky/MainActivity.kt | 46 ++++- .../keylesspalace/tusky/appstore/Events.kt | 3 +- .../announcements/AnnouncementAdapter.kt | 115 +++++++++++ .../announcements/AnnouncementsActivity.kt | 153 ++++++++++++++ .../announcements/AnnouncementsViewModel.kt | 187 ++++++++++++++++++ .../components/compose/ComposeActivity.kt | 18 +- .../tusky/di/ActivitiesModule.kt | 4 + .../tusky/di/ViewModelFactory.kt | 8 +- .../tusky/entity/Announcement.kt | 57 ++++++ .../com/keylesspalace/tusky/entity/Emoji.kt | 3 +- .../tusky/network/MastodonApi.kt | 22 +++ .../keylesspalace/tusky/view/EmojiPicker.kt | 17 ++ .../main/res/drawable/ic_bullhorn_24dp.xml | 9 + .../res/layout/activity_announcements.xml | 39 ++++ app/src/main/res/layout/activity_compose.xml | 4 +- app/src/main/res/layout/item_announcement.xml | 41 ++++ app/src/main/res/values/strings.xml | 2 + app/src/main/res/values/styles.xml | 1 + 19 files changed, 717 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/view/EmojiPicker.kt create mode 100644 app/src/main/res/drawable/ic_bullhorn_24dp.xml create mode 100644 app/src/main/res/layout/activity_announcements.xml create mode 100644 app/src/main/res/layout/item_announcement.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 72aa387ab..585ff833c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,7 +36,7 @@ + android:configChanges="orientation|screenSize|keyboardHidden" /> @@ -105,7 +105,7 @@ + android:windowSoftInputMode="stateVisible|adjustResize" /> @@ -145,6 +145,7 @@ android:windowSoftInputMode="stateAlwaysHidden|adjustResize" /> + + tools:node="remove" /> - \ No newline at end of file + diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 3013aeeb9..c91b20cb8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -47,6 +47,7 @@ import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayoutMediator import com.keylesspalace.tusky.appstore.* +import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType import com.keylesspalace.tusky.components.conversation.ConversationsRepository @@ -67,6 +68,9 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp +import com.mikepenz.materialdrawer.holder.BadgeStyle +import com.mikepenz.materialdrawer.holder.ColorHolder +import com.mikepenz.materialdrawer.holder.StringHolder import com.mikepenz.materialdrawer.iconics.iconicsIcon import com.mikepenz.materialdrawer.model.* import com.mikepenz.materialdrawer.model.interfaces.* @@ -97,6 +101,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private var notificationTabPosition = 0 private var onTabSelectedListener: OnTabSelectedListener? = null + private var unreadAnnouncementsCount = 0 + private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } private val emojiInitCallback = object : InitCallback() { @@ -191,6 +197,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje * drawer, though, because its callback touches the header in the drawer. */ fetchUserInfo() + fetchAnnouncements() + setupTabs(showNotificationTab) // Setup push notifications @@ -206,6 +214,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje when (event) { is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData) is MainTabsChangedEvent -> setupTabs(false) + is AnnouncementReadEvent -> { + unreadAnnouncementsCount-- + updateAnnouncementsBadge() + } } } @@ -392,6 +404,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(context)) } }, + primaryDrawerItem { + identifier = DRAWER_ITEM_ANNOUNCEMENTS + nameRes = R.string.title_announcements + iconRes = R.drawable.ic_bullhorn_24dp + onClick = { + startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context)) + } + badgeStyle = BadgeStyle().apply { + textColor = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorOnPrimary)) + color = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorPrimary)) + } + }, DividerDrawerItem(), secondaryDrawerItem { nameRes = R.string.action_view_account_preferences @@ -653,6 +677,25 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje updateShortcut(this, accountManager.activeAccount!!) } + private fun fetchAnnouncements() { + mastodonApi.listAnnouncements(false) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this, Lifecycle.Event.ON_DESTROY) + .subscribe( + { announcements -> + unreadAnnouncementsCount = announcements.count { !it.read } + updateAnnouncementsBadge() + }, + { + Log.w(TAG, "Failed to fetch announcements.", it) + } + ) + } + + private fun updateAnnouncementsBadge() { + mainDrawer.updateBadge(DRAWER_ITEM_ANNOUNCEMENTS, StringHolder(if (unreadAnnouncementsCount == 0) null else unreadAnnouncementsCount.toString())) + } + private fun updateProfiles() { val profiles: MutableList = accountManager.getAllAccountsOrderedByActive().map { acc -> val emojifiedName = EmojiCompat.get().process(acc.displayName.emojify(acc.emojis, header)) @@ -687,6 +730,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private const val TAG = "MainActivity" // logging tag private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13 private const val DRAWER_ITEM_FOLLOW_REQUESTS: Long = 10 + private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14 const val STATUS_URL = "statusUrl" } } @@ -716,4 +760,4 @@ private var AbstractDrawerItem<*, *>.onClick: () -> Unit value() false } - } \ No newline at end of file + } diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index 8e62faeb3..288de430f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -19,4 +19,5 @@ data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable data class MainTabsChangedEvent(val newTabs: List) : Dispatchable data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable -data class DomainMuteEvent(val instance: String): Dispatchable \ No newline at end of file +data class DomainMuteEvent(val instance: String): Dispatchable +data class AnnouncementReadEvent(val announcementId: String): Dispatchable diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt new file mode 100644 index 000000000..c4fa93f24 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt @@ -0,0 +1,115 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.announcements + +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.view.size +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Announcement +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.util.emojify +import kotlinx.android.synthetic.main.item_announcement.view.* + +interface AnnouncementActionListener { + fun openReactionPicker(announcementId: String, target: View) + fun addReaction(announcementId: String, name: String) + fun removeReaction(announcementId: String, name: String) +} + +class AnnouncementAdapter( + private var items: List = emptyList(), + private val listener: AnnouncementActionListener +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AnnouncementViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_announcement, parent, false) + return AnnouncementViewHolder(view) + } + + override fun onBindViewHolder(viewHolder: AnnouncementViewHolder, position: Int) { + viewHolder.bind(items[position]) + } + + override fun getItemCount() = items.size + + fun updateList(items: List) { + this.items = items + notifyDataSetChanged() + } + + inner class AnnouncementViewHolder(private val view: View) : RecyclerView.ViewHolder(view) { + + private val text: TextView = view.text + private val chips: ChipGroup = view.chipGroup + private val addReactionChip: Chip = view.addReactionChip + + fun bind(item: Announcement) { + text.text = item.content + + item.reactions.forEachIndexed { i, reaction -> + (chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip? + ?: Chip(ContextThemeWrapper(view.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply { + isCheckable = true + checkedIcon = null + chips.addView(this, i) + }) + .apply { + val emojiText = if (reaction.url == null) { + reaction.name + } else { + view.context.getString(R.string.emoji_shortcode_format, reaction.name) + } + text = ("$emojiText ${reaction.count}") + .emojify( + listOf(Emoji( + reaction.name, + reaction.url ?: "", + reaction.staticUrl ?: "", + null + )), + this + ) + + isChecked = reaction.me + + setOnClickListener { + if (reaction.me) { + listener.removeReaction(item.id, reaction.name) + } else { + listener.addReaction(item.id, reaction.name) + } + } + } + } + + while (chips.size - 1 > item.reactions.size) { + chips.removeViewAt(item.reactions.size) + } + + addReactionChip.setOnClickListener { + listener.openReactionPicker(item.id, it) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt new file mode 100644 index 000000000..f9c0ae725 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt @@ -0,0 +1,153 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.announcements + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.widget.PopupWindow +import androidx.activity.viewModels +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.EmojiAdapter +import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.view.EmojiPicker +import kotlinx.android.synthetic.main.activity_announcements.* +import kotlinx.android.synthetic.main.toolbar_basic.* +import javax.inject.Inject + +class AnnouncementsActivity : BaseActivity(), AnnouncementActionListener, OnEmojiSelectedListener, Injectable { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: AnnouncementsViewModel by viewModels { viewModelFactory } + + private val adapter = AnnouncementAdapter(emptyList(), this) + + private val picker by lazy { EmojiPicker(this) } + private val pickerDialog by lazy { + PopupWindow(this) + .apply { + contentView = picker + isFocusable = true + setOnDismissListener { + currentAnnouncementId = null + } + } + } + private var currentAnnouncementId: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_announcements) + + setSupportActionBar(toolbar) + supportActionBar?.apply { + title = getString(R.string.title_announcements) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + swipeRefreshLayout.setOnRefreshListener(this::refreshAnnouncements) + swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + + announcementsList.setHasFixedSize(true) + announcementsList.layoutManager = LinearLayoutManager(this) + val divider = DividerItemDecoration(this, DividerItemDecoration.VERTICAL) + announcementsList.addItemDecoration(divider) + announcementsList.adapter = adapter + + viewModel.announcements.observe(this, Observer { + when (it) { + is Success -> { + progressBar.hide() + swipeRefreshLayout.isRefreshing = false + if (it.data.isNullOrEmpty()) { + errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_announcements) + errorMessageView.show() + } else { + errorMessageView.hide() + } + adapter.updateList(it.data ?: listOf()) + } + is Loading -> { + errorMessageView.hide() + } + is Error -> { + progressBar.hide() + swipeRefreshLayout.isRefreshing = false + errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { + refreshAnnouncements() + } + errorMessageView.show() + } + } + }) + + viewModel.emojis.observe(this, Observer { + picker.adapter = EmojiAdapter(it, this) + }) + + viewModel.load() + progressBar.show() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } + + private fun refreshAnnouncements() { + viewModel.load() + swipeRefreshLayout.isRefreshing = true + } + + override fun openReactionPicker(announcementId: String, target: View) { + currentAnnouncementId = announcementId + pickerDialog.showAsDropDown(target) + } + + override fun onEmojiSelected(shortcode: String) { + viewModel.addReaction(currentAnnouncementId!!, shortcode) + pickerDialog.dismiss() + } + + override fun addReaction(announcementId: String, name: String) { + viewModel.addReaction(announcementId, name) + } + + override fun removeReaction(announcementId: String, name: String) { + viewModel.removeReaction(announcementId, name) + } + + companion object { + fun newIntent(context: Context) = Intent(context, AnnouncementsActivity::class.java) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt new file mode 100644 index 000000000..2fd1fbae4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt @@ -0,0 +1,187 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.announcements + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.keylesspalace.tusky.appstore.AnnouncementReadEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.InstanceEntity +import com.keylesspalace.tusky.entity.Announcement +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Instance +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.* +import io.reactivex.rxkotlin.Singles +import javax.inject.Inject + +class AnnouncementsViewModel @Inject constructor( + accountManager: AccountManager, + private val appDatabase: AppDatabase, + private val mastodonApi: MastodonApi, + private val eventHub: EventHub +) : RxAwareViewModel() { + + private val announcementsMutable = MutableLiveData>>() + val announcements: LiveData>> = announcementsMutable + + private val emojisMutable = MutableLiveData>() + val emojis: LiveData> = emojisMutable + + init { + Singles.zip( + mastodonApi.getCustomEmojis(), + appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) + .map> { Either.Left(it) } + .onErrorResumeNext( + mastodonApi.getInstance() + .map { Either.Right(it) } + ) + ) { emojis, either -> + either.asLeftOrNull()?.copy(emojiList = emojis) + ?: InstanceEntity( + accountManager.activeAccount?.domain!!, + emojis, + either.asRight().maxTootChars, + either.asRight().pollLimits?.maxOptions, + either.asRight().pollLimits?.maxOptionChars, + either.asRight().version + ) + } + .doOnSuccess { + appDatabase.instanceDao().insertOrReplace(it) + } + .subscribe({ + emojisMutable.postValue(it.emojiList) + }, { + Log.w(TAG, "Failed to get custom emojis.", it) + }) + .autoDispose() + } + + fun load() { + announcementsMutable.postValue(Loading()) + mastodonApi.listAnnouncements() + .subscribe({ + announcementsMutable.postValue(Success(it)) + it.filter { announcement -> !announcement.read } + .forEach { announcement -> + mastodonApi.dismissAnnouncement(announcement.id) + .subscribe( + { + eventHub.dispatch(AnnouncementReadEvent(announcement.id)) + }, + { throwable -> + Log.d(TAG, "Failed to mark announcement as read.", throwable) + } + ) + .autoDispose() + } + }, { + announcementsMutable.postValue(Error(cause = it)) + }) + .autoDispose() + } + + fun addReaction(announcementId: String, name: String) { + mastodonApi.addAnnouncementReaction(announcementId, name) + .subscribe({ + announcementsMutable.postValue( + Success( + announcements.value!!.data!!.map { announcement -> + if (announcement.id == announcementId) { + announcement.copy( + reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) { + announcement.reactions.map { reaction -> + if (reaction.name == name) { + reaction.copy( + count = reaction.count + 1, + me = true + ) + } else { + reaction + } + } + } else { + listOf( + *announcement.reactions.toTypedArray(), + emojis.value!!.find { emoji -> emoji.shortcode == name } + !!.run { + Announcement.Reaction( + name, + 1, + true, + url, + staticUrl + ) + } + ) + } + ) + } else { + announcement + } + } + ) + ) + }, { + Log.w(TAG, "Failed to add reaction to the announcement.", it) + }) + .autoDispose() + } + + fun removeReaction(announcementId: String, name: String) { + mastodonApi.removeAnnouncementReaction(announcementId, name) + .subscribe({ + announcementsMutable.postValue( + Success( + announcements.value!!.data!!.map { announcement -> + if (announcement.id == announcementId) { + announcement.copy( + reactions = announcement.reactions.mapNotNull { reaction -> + if (reaction.name == name) { + if (reaction.count > 1) { + reaction.copy( + count = reaction.count - 1, + me = false + ) + } else { + null + } + } else { + reaction + } + } + ) + } else { + announcement + } + } + ) + ) + }, { + Log.w(TAG, "Failed to remove reaction from the announcement.", it) + }) + .autoDispose() + } + + companion object { + private const val TAG = "AnnouncementsViewModel" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index 45f72a3ef..4eba7e321 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -50,7 +50,6 @@ import androidx.core.view.inputmethod.InputContentInfoCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.transition.TransitionManager import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -301,7 +300,7 @@ class ComposeActivity : BaseActivity(), } viewModel.media.observe { media -> mediaAdapter.submitList(media) - if(media.size != mediaCount) { + if (media.size != mediaCount) { mediaCount = media.size composeMediaPreviewBar.visible(media.isNotEmpty()) updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false) @@ -311,8 +310,8 @@ class ComposeActivity : BaseActivity(), pollPreview.visible(poll != null) poll?.let(pollPreview::setPoll) } - viewModel.scheduledAt.observe {scheduledAt -> - if(scheduledAt == null) { + viewModel.scheduledAt.observe { scheduledAt -> + if (scheduledAt == null) { composeScheduleView.resetSchedule() } else { composeScheduleView.setDateTime(scheduledAt) @@ -344,7 +343,6 @@ class ComposeActivity : BaseActivity(), scheduleBehavior = BottomSheetBehavior.from(composeScheduleView) emojiBehavior = BottomSheetBehavior.from(emojiView) - emojiView.layoutManager = GridLayoutManager(this, 3, GridLayoutManager.HORIZONTAL, false) enableButton(composeEmojiButton, clickable = false, colorActive = false) // Setup the interface buttons. @@ -552,7 +550,7 @@ class ComposeActivity : BaseActivity(), } private fun onScheduleClick() { - if(viewModel.scheduledAt.value == null) { + if (viewModel.scheduledAt.value == null) { composeScheduleView.openPickDateDialog() } else { showScheduleView() @@ -715,9 +713,9 @@ class ComposeActivity : BaseActivity(), // Verify the returned content's type is of the correct MIME type val supported = inputContentInfo.description.hasMimeType("image/*") - if(supported) { + if (supported) { val lacksPermission = (flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0 - if(lacksPermission) { + if (lacksPermission) { try { inputContentInfo.requestPermission() } catch (e: Exception) { @@ -771,7 +769,7 @@ class ComposeActivity : BaseActivity(), Snackbar.LENGTH_SHORT).apply { } - bar.setAction(R.string.action_retry) { onMediaPick()} + bar.setAction(R.string.action_retry) { onMediaPick() } //necessary so snackbar is shown over everything bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) bar.show() @@ -913,7 +911,7 @@ class ComposeActivity : BaseActivity(), override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { Log.d(TAG, event.toString()) - if(event.action == KeyEvent.ACTION_DOWN) { + if (event.action == KeyEvent.ACTION_DOWN) { if (event.isCtrlPressed) { if (keyCode == KeyEvent.KEYCODE_ENTER) { // send toot by pressing CTRL + ENTER diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index ea2741ca5..0257c28f6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.di import com.keylesspalace.tusky.* +import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.preference.PreferencesActivity @@ -103,4 +104,7 @@ abstract class ActivitiesModule { @ContributesAndroidInjector abstract fun contributesScheduledTootActivity(): ScheduledTootActivity + + @ContributesAndroidInjector + abstract fun contributesAnnouncementsActivity(): AnnouncementsActivity } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index f49294639..c461012db 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -4,6 +4,7 @@ package com.keylesspalace.tusky.di import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.components.report.ReportViewModel @@ -85,5 +86,10 @@ abstract class ViewModelModule { @ViewModelKey(ScheduledTootViewModel::class) internal abstract fun scheduledTootViewModel(viewModel: ScheduledTootViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(AnnouncementsViewModel::class) + internal abstract fun announcementsViewModel(viewModel: AnnouncementsViewModel): ViewModel + //Add more ViewModels here -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt new file mode 100644 index 000000000..5cd32fe8d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt @@ -0,0 +1,57 @@ +/* Copyright 2020 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import android.text.Spanned +import com.google.gson.annotations.SerializedName +import java.util.* + +data class Announcement( + val id: String, + val content: Spanned, + @SerializedName("starts_at") val startsAt: Date?, + @SerializedName("ends_at") val endsAt: Date?, + @SerializedName("all_day") val allDay: Boolean, + @SerializedName("published_at") val publishedAt: Date, + @SerializedName("updated_at") val updatedAt: Date, + val read: Boolean, + val mentions: List, + val statuses: List, + val tags: List, + val emojis: List, + val reactions: List +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + + val announcement = other as Announcement? + return id == announcement?.id + } + + override fun hashCode(): Int { + return id.hashCode() + } + + data class Reaction( + val name: String, + var count: Int, + var me: Boolean, + val url: String?, + @SerializedName("static_url") val staticUrl: String? + ) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt index baee54bcc..fe7a22c73 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt @@ -23,5 +23,6 @@ import kotlinx.android.parcel.Parcelize data class Emoji( val shortcode: String, val url: String, + @SerializedName("static_url") val staticUrl: String, @SerializedName("visible_in_picker") val visibleInPicker: Boolean? -) : Parcelable \ No newline at end of file +) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index a43ac9b08..8f3dab3fd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -513,6 +513,28 @@ interface MastodonApi { @Field("choices[]") choices: List ): Single + @GET("api/v1/announcements") + fun listAnnouncements( + @Query("with_dismissed") withDismissed: Boolean = true + ): Single> + + @POST("api/v1/announcements/{id}/dismiss") + fun dismissAnnouncement( + @Path("id") announcementId: String + ): Single + + @PUT("api/v1/announcements/{id}/reactions/{name}") + fun addAnnouncementReaction( + @Path("id") announcementId: String, + @Path("name") name: String + ): Single + + @DELETE("api/v1/announcements/{id}/reactions/{name}") + fun removeAnnouncementReaction( + @Path("id") announcementId: String, + @Path("name") name: String + ): Single + @FormUrlEncoded @POST("api/v1/reports") fun reportObservable( diff --git a/app/src/main/java/com/keylesspalace/tusky/view/EmojiPicker.kt b/app/src/main/java/com/keylesspalace/tusky/view/EmojiPicker.kt new file mode 100644 index 000000000..09e648adf --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/EmojiPicker.kt @@ -0,0 +1,17 @@ +package com.keylesspalace.tusky.view + +import android.content.Context +import android.util.AttributeSet +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView + +class EmojiPicker @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : RecyclerView(context, attrs) { + + init { + clipToPadding = false + layoutManager = GridLayoutManager(context, 3, GridLayoutManager.HORIZONTAL, false) + } +} diff --git a/app/src/main/res/drawable/ic_bullhorn_24dp.xml b/app/src/main/res/drawable/ic_bullhorn_24dp.xml new file mode 100644 index 000000000..e290b24ea --- /dev/null +++ b/app/src/main/res/drawable/ic_bullhorn_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_announcements.xml b/app/src/main/res/layout/activity_announcements.xml new file mode 100644 index 000000000..c0504b83c --- /dev/null +++ b/app/src/main/res/layout/activity_announcements.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index 355841d98..5f8d6ffd6 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -193,14 +193,12 @@ android:textSize="?attr/status_text_medium" /> - + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 32602bfdf..bc340a3d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -42,6 +42,7 @@ Edit your profile Drafts Scheduled toots + Announcements Licenses \@%s @@ -569,6 +570,7 @@ You don\'t have any drafts. You don\'t have any scheduled statuses. + There are no announcements. Mastodon has a minimum scheduling interval of 5 minutes. Show link previews in timelines Show confirmation dialog before boosting diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 0c9c66f44..50da2622e 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -79,6 +79,7 @@ ?attr/colorSurface + @style/Widget.MaterialComponents.Chip.Choice