Add context menus to story contacts in contact selection.

This commit is contained in:
Alex Hart 2022-06-29 09:57:06 -03:00 committed by Cody Henthorne
parent 7bd34d2b99
commit c64be82710
15 changed files with 294 additions and 11 deletions

View file

@ -96,7 +96,6 @@ import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
import kotlin.Unit;

View file

@ -0,0 +1,59 @@
package org.thoughtcrime.securesms.components
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.fragments.findListener
/**
* Convenience class for wrapping Fragments in full-screen dialogs. Due to how fragments work, they
* must be public static classes. Therefore, this class should be subclassed as its own entity, rather
* than via `object : WrapperDialogFragment`.
*
* Example usage:
*
* ```
* class Dialog : WrapperDialogFragment() {
* override fun getWrappedFragment(): Fragment {
* return NavHostFragment.create(R.navigation.private_story_settings, requireArguments())
* }
* }
*
* companion object {
* fun createAsDialog(distributionListId: DistributionListId): DialogFragment {
* return Dialog().apply {
* arguments = PrivateStorySettingsFragmentArgs.Builder(distributionListId).build().toBundle()
* }
* }
* }
* ```
*/
abstract class WrapperDialogFragment : DialogFragment(R.layout.fragment_container) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (savedInstanceState == null) {
childFragmentManager.beginTransaction()
.replace(R.id.fragment_container, getWrappedFragment())
.commitAllowingStateLoss()
}
}
override fun onDismiss(dialog: DialogInterface) {
findListener<WrapperDialogFragmentCallback>()?.onWrapperDialogFragmentDismissed()
}
abstract fun getWrappedFragment(): Fragment
interface WrapperDialogFragmentCallback {
fun onWrapperDialogFragmentDismissed()
}
}

View file

@ -1,12 +1,15 @@
package org.thoughtcrime.securesms.components.menu
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import org.thoughtcrime.securesms.R
/**
* Represents an action to be rendered via [SignalContextMenu] or [SignalBottomActionBar]
*/
data class ActionItem(
data class ActionItem @JvmOverloads constructor(
@DrawableRes val iconRes: Int,
val title: CharSequence,
val action: Runnable
@ColorRes val tintRes: Int = R.color.signal_colorOnSurface,
val action: Runnable,
)

View file

@ -4,6 +4,7 @@ import android.os.Build
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
@ -78,6 +79,10 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
onItemClick()
}
val tintColor = ContextCompat.getColor(context, model.item.tintRes)
icon.setColorFilter(tintColor)
title.setTextColor(tintColor)
if (Build.VERSION.SDK_INT >= 21) {
when (model.displayType) {
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_top)

View file

@ -1,12 +1,15 @@
package org.thoughtcrime.securesms.contacts.paged
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.FromTextView
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@ -27,11 +30,12 @@ object ContactSearchItems {
displayCheckBox: Boolean,
recipientListener: RecipientClickListener,
storyListener: StoryClickListener,
storyContextMenuCallbacks: StoryContextMenuCallbacks,
expandListener: (ContactSearchData.Expand) -> Unit
) {
mappingAdapter.registerFactory(
StoryModel::class.java,
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener) }, R.layout.contact_search_item)
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener, storyContextMenuCallbacks) }, R.layout.contact_search_item)
)
mappingAdapter.registerFactory(
RecipientModel::class.java,
@ -82,7 +86,7 @@ object ContactSearchItems {
}
}
private class StoryViewHolder(itemView: View, displayCheckBox: Boolean, onClick: StoryClickListener) : BaseRecipientViewHolder<StoryModel, ContactSearchData.Story>(itemView, displayCheckBox, onClick) {
private class StoryViewHolder(itemView: View, displayCheckBox: Boolean, onClick: StoryClickListener, private val storyContextMenuCallbacks: StoryContextMenuCallbacks) : BaseRecipientViewHolder<StoryModel, ContactSearchData.Story>(itemView, displayCheckBox, onClick) {
override fun isSelected(model: StoryModel): Boolean = model.isSelected
override fun getData(model: StoryModel): ContactSearchData.Story = model.story
override fun getRecipient(model: StoryModel): Recipient = model.story.recipient
@ -104,6 +108,50 @@ object ContactSearchItems {
number.text = context.resources.getQuantityString(pluralId, count, count)
}
override fun bindLongPress(model: StoryModel) {
itemView.setOnLongClickListener {
val actions: List<ActionItem> = when {
model.story.recipient.isMyStory -> getMyStoryContextMenuActions(model)
model.story.recipient.isGroup -> getGroupStoryContextMenuActions(model)
model.story.recipient.isDistributionList -> getPrivateStoryContextMenuActions(model)
else -> error("Unsupported story target. Not a group or distribution list.")
}
SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup)
.offsetX(context.resources.getDimensionPixelSize(R.dimen.dsl_settings_gutter))
.show(actions)
true
}
}
private fun getMyStoryContextMenuActions(model: StoryModel): List<ActionItem> {
return listOf(
ActionItem(R.drawable.ic_settings_24, context.getString(R.string.ContactSearchItems__story_settings)) {
storyContextMenuCallbacks.onOpenStorySettings(model.story)
}
)
}
private fun getGroupStoryContextMenuActions(model: StoryModel): List<ActionItem> {
return listOf(
ActionItem(R.drawable.ic_minus_circle_20, context.getString(R.string.ContactSearchItems__remove_story)) {
storyContextMenuCallbacks.onRemoveGroupStory(model.story, model.isSelected)
}
)
}
private fun getPrivateStoryContextMenuActions(model: StoryModel): List<ActionItem> {
return listOf(
ActionItem(R.drawable.ic_settings_24, context.getString(R.string.ContactSearchItems__story_settings)) {
storyContextMenuCallbacks.onOpenStorySettings(model.story)
},
ActionItem(R.drawable.ic_delete_24, context.getString(R.string.ContactSearchItems__delete_story), R.color.signal_colorError) {
storyContextMenuCallbacks.onDeletePrivateStory(model.story, model.isSelected)
}
)
}
}
/**
@ -151,6 +199,7 @@ object ContactSearchItems {
checkbox.visible = displayCheckBox
checkbox.isChecked = isSelected(model)
itemView.setOnClickListener { onClick(itemView, getData(model), isSelected(model)) }
bindLongPress(model)
if (payload.isNotEmpty()) {
return
@ -188,6 +237,8 @@ object ContactSearchItems {
smsTag.visible = isSmsContact(model)
}
protected open fun bindLongPress(model: T) = Unit
private fun isSmsContact(model: T): Boolean {
return (getRecipient(model).isForceSmsSelection || getRecipient(model).isUnregistered) && !getRecipient(model).isDistributionList
}
@ -271,4 +322,10 @@ object ContactSearchItems {
return if (isLeftSelf == isRightSelf) 0 else if (isLeftSelf) 1 else -1
}
}
interface StoryContextMenuCallbacks {
fun onOpenStorySettings(story: ContactSearchData.Story)
fun onRemoveGroupStory(story: ContactSearchData.Story, isSelected: Boolean)
fun onDeletePrivateStory(story: ContactSearchData.Story, isSelected: Boolean)
}
}

View file

@ -1,16 +1,22 @@
package org.thoughtcrime.securesms.contacts.paged
import android.view.View
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.stories.settings.custom.PrivateStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.my.MyStorySettingsFragment
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
class ContactSearchMediator(
fragment: Fragment,
private val fragment: Fragment,
recyclerView: RecyclerView,
selectionLimits: SelectionLimits,
displayCheckBox: Boolean,
@ -30,6 +36,7 @@ class ContactSearchMediator(
displayCheckBox = displayCheckBox,
recipientListener = this::toggleSelection,
storyListener = this::toggleSelection,
storyContextMenuCallbacks = StoryContextMenuCallbacks(),
expandListener = { viewModel.expandSection(it.sectionKey) }
)
@ -76,6 +83,10 @@ class ContactSearchMediator(
viewModel.addToVisibleGroupStories(groupStories)
}
fun refresh() {
viewModel.refresh()
}
private fun toggleSelection(view: View, contactSearchData: ContactSearchData, isSelected: Boolean) {
return if (isSelected) {
viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey))
@ -83,4 +94,34 @@ class ContactSearchMediator(
viewModel.setKeysSelected(contactSelectionPreFilter(view, setOf(contactSearchData.contactSearchKey)))
}
}
private inner class StoryContextMenuCallbacks : ContactSearchItems.StoryContextMenuCallbacks {
override fun onOpenStorySettings(story: ContactSearchData.Story) {
if (story.recipient.isMyStory) {
MyStorySettingsFragment.createAsDialog()
.show(fragment.childFragmentManager, null)
} else {
PrivateStorySettingsFragment.createAsDialog(story.recipient.requireDistributionListId())
.show(fragment.childFragmentManager, null)
}
}
override fun onRemoveGroupStory(story: ContactSearchData.Story, isSelected: Boolean) {
MaterialAlertDialogBuilder(fragment.requireContext())
.setTitle(R.string.ContactSearchMediator__remove_group_story)
.setMessage(R.string.ContactSearchMediator__this_will_remove)
.setPositiveButton(R.string.ContactSearchMediator__remove) { _, _ -> viewModel.removeGroupStory(story) }
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
override fun onDeletePrivateStory(story: ContactSearchData.Story, isSelected: Boolean) {
MaterialAlertDialogBuilder(fragment.requireContext())
.setTitle(R.string.ContactSearchMediator__delete_story)
.setMessage(fragment.getString(R.string.ContactSearchMediator__delete_the_private, story.recipient.getDisplayName(fragment.requireContext())))
.setPositiveButton(SpanUtil.color(ContextCompat.getColor(fragment.requireContext(), R.color.signal_colorError), fragment.getString(R.string.ContactSearchMediator__delete))) { _, _ -> viewModel.deletePrivateStory(story) }
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
}
}

View file

@ -1,9 +1,14 @@
package org.thoughtcrime.securesms.contacts.paged
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.Stories
class ContactSearchRepository {
fun filterOutUnselectableContactSearchKeys(contactSearchKeys: Set<ContactSearchKey>): Single<Set<ContactSearchSelectionResult>> {
@ -29,4 +34,17 @@ class ContactSearchRepository {
true
}
}
fun unmarkDisplayAsStory(groupId: GroupId): Completable {
return Completable.fromAction {
SignalDatabase.groups.markDisplayAsStory(groupId, false)
}.subscribeOn(Schedulers.io())
}
fun deletePrivateStory(distributionListId: DistributionListId): Completable {
return Completable.fromAction {
SignalDatabase.distributionLists.deleteList(distributionListId)
Stories.onStorySettingsChanged(distributionListId)
}.subscribeOn(Schedulers.io())
}
}

View file

@ -14,6 +14,7 @@ import org.signal.paging.PagingController
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.signalservice.api.util.Preconditions
/**
* Simple, reusable view model that manages a ContactSearchPagedDataSource as well as filter and expansion state.
@ -97,6 +98,31 @@ class ContactSearchViewModel(
}
}
fun removeGroupStory(story: ContactSearchData.Story) {
Preconditions.checkArgument(story.recipient.isGroup)
setKeysNotSelected(setOf(story.contactSearchKey))
disposables += contactSearchRepository.unmarkDisplayAsStory(story.recipient.requireGroupId()).subscribe {
configurationStore.update { state ->
state.copy(
groupStories = state.groupStories.filter { it.recipient.id == story.recipient.id }.toSet()
)
}
refresh()
}
}
fun deletePrivateStory(story: ContactSearchData.Story) {
Preconditions.checkArgument(story.recipient.isDistributionList && !story.recipient.isMyStory)
setKeysNotSelected(setOf(story.contactSearchKey))
disposables += contactSearchRepository.deletePrivateStory(story.recipient.requireDistributionListId()).subscribe {
refresh()
}
}
fun refresh() {
controller.value?.onDataInvalidated()
}
class Factory(private val selectionLimits: SelectionLimits, private val repository: ContactSearchRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ContactSearchViewModel(selectionLimits, repository)) as T

View file

@ -29,6 +29,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ContactFilterView
import org.thoughtcrime.securesms.components.TooltipPopup
import org.thoughtcrime.securesms.components.WrapperDialogFragment
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator
@ -75,7 +76,8 @@ import org.thoughtcrime.securesms.util.visible
class MultiselectForwardFragment :
Fragment(R.layout.multiselect_forward_fragment),
SafetyNumberChangeDialog.Callback,
ChooseStoryTypeBottomSheet.Callback {
ChooseStoryTypeBottomSheet.Callback,
WrapperDialogFragment.WrapperDialogFragmentCallback {
private val viewModel: MultiselectForwardViewModel by viewModels(factoryProducer = this::createViewModelFactory)
private val disposables = LifecycleDisposable()
@ -542,4 +544,8 @@ class MultiselectForwardFragment :
}
}
}
override fun onWrapperDialogFragmentDismissed() {
contactSearchMediator.refresh()
}
}

View file

@ -1418,8 +1418,12 @@ public class GroupDatabase extends Database {
}
public void markDisplayAsStory(@NonNull GroupId groupId) {
markDisplayAsStory(groupId, true);
}
public void markDisplayAsStory(@NonNull GroupId groupId, boolean displayAsStory) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(DISPLAY_AS_STORY, true);
contentValues.put(DISPLAY_AS_STORY, displayAsStory);
getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?", SqlUtil.buildArgs(groupId.toString()));
}

View file

@ -14,6 +14,7 @@ import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.WrapperDialogFragment
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator
@ -35,7 +36,7 @@ import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragment), ChooseStoryTypeBottomSheet.Callback {
class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragment), ChooseStoryTypeBottomSheet.Callback, WrapperDialogFragment.WrapperDialogFragmentCallback {
private lateinit var shareListWrapper: View
private lateinit var shareSelectionRecyclerView: RecyclerView
@ -195,4 +196,8 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragm
override fun onGroupStoryClicked() {
ChooseGroupStoryBottomSheet().show(parentFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
override fun onWrapperDialogFragmentDismissed() {
contactSearchMediator.refresh()
}
}

View file

@ -3,10 +3,14 @@ package org.thoughtcrime.securesms.stories.settings.custom
import android.view.MenuItem
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.WrapperDialogFragment
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
@ -16,6 +20,7 @@ import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.stories.settings.story.PrivateStoryItem
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.fragments.findListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewholders.RecipientMappingModel
import org.thoughtcrime.securesms.util.viewholders.RecipientViewHolder
@ -118,9 +123,27 @@ class PrivateStorySettingsFragment : DSLSettingsFragment(
.show()
}
override fun onToolbarNavigationClicked() {
findListener<WrapperDialogFragment>()?.dismiss() ?: super.onToolbarNavigationClicked()
}
inner class RecipientEventListener : RecipientViewHolder.EventListener<RecipientMappingModel.RecipientIdMappingModel> {
override fun onClick(recipient: Recipient) {
handleRemoveRecipient(recipient)
}
}
class Dialog : WrapperDialogFragment() {
override fun getWrappedFragment(): Fragment {
return NavHostFragment.create(R.navigation.private_story_settings, requireArguments())
}
}
companion object {
fun createAsDialog(distributionListId: DistributionListId): DialogFragment {
return Dialog().apply {
arguments = PrivateStorySettingsFragmentArgs.Builder(distributionListId).build().toBundle()
}
}
}
}

View file

@ -2,9 +2,13 @@ package org.thoughtcrime.securesms.stories.settings.my
import android.os.Bundle
import android.view.View
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.WrapperDialogFragment
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
@ -12,6 +16,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.fragments.findListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class MyStorySettingsFragment : DSLSettingsFragment(
@ -105,4 +110,20 @@ class MyStorySettingsFragment : DSLSettingsFragment(
)
}
}
override fun onToolbarNavigationClicked() {
findListener<WrapperDialogFragment>()?.dismiss() ?: super.onToolbarNavigationClicked()
}
class Dialog : WrapperDialogFragment() {
override fun getWrappedFragment(): Fragment {
return NavHostFragment.create(R.navigation.my_story_settings)
}
}
companion object {
fun createAsDialog(): DialogFragment {
return Dialog()
}
}
}

View file

@ -17,7 +17,6 @@
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
app:tint="@color/signal_colorOnSurface"
tools:src="@drawable/ic_archive_24dp" />
<TextView
@ -27,7 +26,6 @@
android:layout_marginStart="16dp"
android:layout_weight="1"
android:textAppearance="@style/Signal.Text.BodyLarge"
android:textColor="@color/signal_colorOnSurface"
tools:text="Archive" />
</LinearLayout>

View file

@ -4886,6 +4886,24 @@
<item quantity="one">Group Story · %1$d viewer</item>
<item quantity="other">Group Story · %1$d viewers</item>
</plurals>
<!-- Label for context menu item to open story settings -->
<string name="ContactSearchItems__story_settings">Story settings</string>
<!-- Label for context menu item to remove a group story from contact results -->
<string name="ContactSearchItems__remove_story">Remove story</string>
<!-- Label for context menu item to delete a private story -->
<string name="ContactSearchItems__delete_story">Delete story</string>
<!-- Dialog title for removing a group story -->
<string name="ContactSearchMediator__remove_group_story">Remove group story?</string>
<!-- Dialog message for removing a group story -->
<string name="ContactSearchMediator__this_will_remove">This will remove the story from this list. You will still be able to view stories from this group.</string>
<!-- Dialog action item for removing a group story -->
<string name="ContactSearchMediator__remove">Remove</string>
<!-- Dialog title for deleting a private story -->
<string name="ContactSearchMediator__delete_story">Delete story?</string>
<!-- Dialog message for deleting a private story -->
<string name="ContactSearchMediator__delete_the_private">Delete the private story \"%1$s\"?</string>
<!-- Dialog action item for deleting a private story -->
<string name="ContactSearchMediator__delete">Delete</string>
<!-- Gift expiry days remaining -->
<plurals name="Gifts__d_days_remaining">
<item quantity="one">%1$d days remaining</item>