From aa14e6e4f213037d24f664b5da93a4ac3b052d48 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 | 43 ++++ .../keylesspalace/tusky/appstore/Events.kt | 1 + .../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 | 29 +-- .../tusky/network/MastodonApi.kt | 61 ++---- .../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 | 2 + 19 files changed, 714 insertions(+), 85 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 a5ffc8b1..7ba6efa5 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" /> @@ -148,6 +148,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 e3246670..352890b3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -48,6 +48,7 @@ import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy 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 @@ -70,6 +71,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.* @@ -104,6 +108,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() { @@ -199,6 +205,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje * drawer, though, because its callback touches the header in the drawer. */ fetchUserInfo() + fetchAnnouncements() + setupTabs(showNotificationTab) eventHub.events @@ -214,6 +222,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje initPullNotifications() } } + is AnnouncementReadEvent -> { + unreadAnnouncementsCount-- + updateAnnouncementsBadge() } } } @@ -416,6 +427,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 @@ -677,6 +700,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, true)) @@ -711,6 +753,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" } } 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 27ad700a..ce986733 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -25,3 +25,4 @@ data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable data class DomainMuteEvent(val instance: String): Dispatchable data class ChatMessageDeliveredEvent(val chatMsg: ChatMessage) : Dispatchable data class ChatMessageReceivedEvent(val chatMsg: ChatMessage) : 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 00000000..c4fa93f2 --- /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 00000000..f9c0ae72 --- /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 00000000..2fd1fbae --- /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 68b6470c..eee6b39d 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 @@ -54,7 +54,6 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.transition.TransitionManager import com.bumptech.glide.Glide @@ -399,7 +398,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) @@ -409,8 +408,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) @@ -445,7 +444,6 @@ class ComposeActivity : BaseActivity(), stickerBehavior = BottomSheetBehavior.from(stickerKeyboard) previewBehavior = BottomSheetBehavior.from(previewScroll) - emojiView.layoutManager = GridLayoutManager(this, 3, GridLayoutManager.HORIZONTAL, false) enableButton(composeEmojiButton, clickable = false, colorActive = false) enableButton(composeStickerButton, false, false) @@ -794,7 +792,7 @@ class ComposeActivity : BaseActivity(), } private fun onScheduleClick() { - if(viewModel.scheduledAt.value == null) { + if (viewModel.scheduledAt.value == null) { composeScheduleView.openPickDateDialog() } else { showScheduleView() @@ -978,9 +976,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) { @@ -1035,7 +1033,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() @@ -1187,7 +1185,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 1d486369..6854eaa5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -17,6 +17,7 @@ package com.keylesspalace.tusky.di import com.keylesspalace.tusky.* import com.keylesspalace.tusky.components.chat.ChatActivity +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 @@ -107,4 +108,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 107a9b54..925c70a0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -5,6 +5,7 @@ package com.keylesspalace.tusky.di import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.keylesspalace.tusky.components.chat.ChatViewModel +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 @@ -91,5 +92,10 @@ abstract class ViewModelModule { @ViewModelKey(ChatViewModel::class) internal abstract fun chatViewModel(viewModel: ChatViewModel) : 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 00000000..5cd32fe8 --- /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 d64215f4..7fabed98 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt @@ -23,34 +23,9 @@ 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 { - constructor(parcel: Parcel) : this( - parcel.readString()!!, - parcel.readString()!!, - parcel.readValue(Boolean::class.java.classLoader) as? Boolean) { - } - - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeString(shortcode) - parcel.writeString(url) - parcel.writeValue(visibleInPicker) - } - - override fun describeContents(): Int { - return 0 - } - - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): Emoji { - return Emoji(parcel) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } -} +) : Parcelable data class EmojiReaction( val name: String, 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 8378bee6..f5319338 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -351,12 +351,12 @@ interface MastodonApi { @POST("api/v1/pleroma/accounts/{id}/subscribe") fun subscribeAccount( @Path("id") accountId: String - ): Call + ): Single @POST("api/v1/pleroma/accounts/{id}/unsubscribe") fun unsubscribeAccount( @Path("id") accountId: String - ): Call + ): Single @GET("api/v1/blocks") fun blocks( @@ -520,44 +520,28 @@ interface MastodonApi { @Field("choices[]") choices: List ): Single -<<<<<<< HEAD - @POST("api/v1/accounts/{id}/block") - fun blockAccountObservable( - @Path("id") accountId: String - ): Single + @GET("api/v1/announcements") + fun listAnnouncements( + @Query("with_dismissed") withDismissed: Boolean = true + ): Single> - @POST("api/v1/accounts/{id}/unblock") - fun unblockAccountObservable( - @Path("id") accountId: String - ): Single - - @POST("api/v1/accounts/{id}/mute") - fun muteAccountObservable( - @Path("id") accountId: String - ): Single + @POST("api/v1/announcements/{id}/dismiss") + fun dismissAnnouncement( + @Path("id") announcementId: String + ): Single - @POST("api/v1/accounts/{id}/unmute") - fun unmuteAccountObservable( - @Path("id") accountId: String - ): Single - - @POST("api/v1/pleroma/accounts/{id}/subscribe") - fun subscribeAccountObservable( - @Path("id") accountId: String - ): Single - - @POST("api/v1/pleroma/accounts/{id}/unsubscribe") - fun unsubscribeAccountObservable( - @Path("id") accountId: String - ): Single + @PUT("api/v1/announcements/{id}/reactions/{name}") + fun addAnnouncementReaction( + @Path("id") announcementId: String, + @Path("name") name: String + ): Single - @GET("api/v1/accounts/relationships") - fun relationshipsObservable( - @Query("id[]") accountIds: List - ): Single> + @DELETE("api/v1/announcements/{id}/reactions/{name}") + fun removeAnnouncementReaction( + @Path("id") announcementId: String, + @Path("name") name: String + ): Single -======= ->>>>>>> ce973ea7... Personal account notes (#1978) @FormUrlEncoded @POST("api/v1/reports") fun reportObservable( @@ -677,18 +661,15 @@ interface MastodonApi { @Path("id") accountId: String ): Single -<<<<<<< HEAD @GET("api/v1/pleroma/chats/{id}") fun getChat( @Path("id") chatId: String ): Single -======= + @FormUrlEncoded @POST("api/v1/accounts/{id}/note") fun updateAccountNote( @Path("id") accountId: String, @Field("comment") note: String ): Single - ->>>>>>> ce973ea7... Personal account notes (#1978) } 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 00000000..09e648ad --- /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 00000000..e290b24e --- /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 00000000..c0504b83 --- /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 78389bac..3ada6d9e 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -251,14 +251,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 32602bfd..bc340a3d 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 7770d303..ea5ace31 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -84,6 +84,8 @@ @color/colorPrimaryDark @color/textColorPrimary @color/textColorSecondary + + @style/Widget.MaterialComponents.Chip.Choice