mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-19 00:01:08 +01:00
Add support for story archiving.
This commit is contained in:
committed by
jeffrey-signal
parent
ff50755ba2
commit
e7d1db446b
@@ -170,6 +170,7 @@ import org.thoughtcrime.securesms.profiles.manage.UsernameEditFragment
|
||||
import org.thoughtcrime.securesms.service.BackupMediaRestoreService
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.stories.archive.StoryArchiveActivity
|
||||
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
|
||||
import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver
|
||||
@@ -1179,6 +1180,10 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
startActivity(StorySettingsActivity.getIntent(this@MainActivity))
|
||||
}
|
||||
|
||||
override fun onStoryArchiveClick() {
|
||||
startActivity(StoryArchiveActivity.createIntent(this@MainActivity))
|
||||
}
|
||||
|
||||
override fun onCloseSearchClick() {
|
||||
toolbarViewModel.setToolbarMode(MainToolbarMode.FULL)
|
||||
}
|
||||
|
||||
@@ -226,6 +226,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
const val PINNING_MESSAGE_ID = "pinning_message_id"
|
||||
const val PINNED_AT = "pinned_at"
|
||||
const val DELETED_BY = "deleted_by"
|
||||
const val STORY_ARCHIVED = "story_archived"
|
||||
|
||||
const val QUOTE_NOT_PRESENT_ID = 0L
|
||||
const val QUOTE_TARGET_MISSING_ID = -1L
|
||||
@@ -297,7 +298,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
$PINNED_UNTIL INTEGER DEFAULT 0,
|
||||
$PINNING_MESSAGE_ID INTEGER DEFAULT 0,
|
||||
$PINNED_AT INTEGER DEFAULT 0,
|
||||
$DELETED_BY INTEGER DEFAULT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE
|
||||
$DELETED_BY INTEGER DEFAULT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE,
|
||||
$STORY_ARCHIVED INTEGER DEFAULT 0
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -331,7 +333,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
"CREATE INDEX IF NOT EXISTS message_votes_unread_index ON $TABLE_NAME ($VOTES_UNREAD)",
|
||||
"CREATE INDEX IF NOT EXISTS message_pinned_until_index ON $TABLE_NAME ($PINNED_UNTIL)",
|
||||
"CREATE INDEX IF NOT EXISTS message_pinned_at_index ON $TABLE_NAME ($PINNED_AT)",
|
||||
"CREATE INDEX IF NOT EXISTS message_deleted_by_index ON $TABLE_NAME ($DELETED_BY)"
|
||||
"CREATE INDEX IF NOT EXISTS message_deleted_by_index ON $TABLE_NAME ($DELETED_BY)",
|
||||
"CREATE INDEX IF NOT EXISTS message_story_archived_index ON $TABLE_NAME ($STORY_ARCHIVED, $STORY_TYPE, $DATE_SENT) WHERE $STORY_TYPE > 0 AND $STORY_ARCHIVED > 0"
|
||||
)
|
||||
|
||||
private val MMS_PROJECTION_BASE = arrayOf(
|
||||
@@ -433,7 +436,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
) AS ${AttachmentTable.ATTACHMENT_JSON_ALIAS}
|
||||
""".toSingleLine()
|
||||
|
||||
private const val IS_STORY_CLAUSE = "$STORY_TYPE > 0 AND $DELETED_BY IS NULL"
|
||||
private const val IS_STORY_CLAUSE = "$STORY_TYPE > 0 AND $DELETED_BY IS NULL AND $STORY_ARCHIVED = 0"
|
||||
private const val IS_ARCHIVED_STORY_CLAUSE = "$STORY_TYPE > 0 AND $DELETED_BY IS NULL AND $STORY_ARCHIVED > 0"
|
||||
private const val RAW_ID_WHERE = "$TABLE_NAME.$ID = ?"
|
||||
|
||||
private val SNIPPET_QUERY =
|
||||
@@ -1683,9 +1687,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
.run()
|
||||
}
|
||||
|
||||
fun deleteStoriesOlderThan(timestamp: Long, hasSeenReleaseChannelStories: Boolean): Int {
|
||||
fun deleteUnarchivedStoriesOlderThan(timestamp: Long, hasSeenReleaseChannelStories: Boolean): Int {
|
||||
return writableDatabase.withinTransaction { db ->
|
||||
val releaseChannelThreadId = getReleaseChannelThreadId(hasSeenReleaseChannelStories)
|
||||
|
||||
val storiesBeforeTimestampWhere = "$IS_STORY_CLAUSE AND $DATE_SENT < ? AND $THREAD_ID != ?"
|
||||
val sharedArgs = buildArgs(timestamp, releaseChannelThreadId)
|
||||
|
||||
@@ -1759,6 +1764,65 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
fun archiveStoriesOlderThan(timestamp: Long, hasSeenReleaseChannelStories: Boolean): Int {
|
||||
val releaseChannelThreadId = getReleaseChannelThreadId(hasSeenReleaseChannelStories)
|
||||
val outgoingFilter = "($outgoingTypeClause)"
|
||||
|
||||
return writableDatabase
|
||||
.update(TABLE_NAME)
|
||||
.values(STORY_ARCHIVED to 1)
|
||||
.where("$IS_STORY_CLAUSE AND $DATE_SENT < ? AND $THREAD_ID != ? AND $outgoingFilter", timestamp, releaseChannelThreadId)
|
||||
.run()
|
||||
}
|
||||
|
||||
fun getArchiveScreenStoriesCount(includeActive: Boolean): Int {
|
||||
val storyClause = if (includeActive) "$STORY_TYPE > 0 AND $DELETED_BY IS NULL" else IS_ARCHIVED_STORY_CLAUSE
|
||||
val where = "$storyClause AND ($outgoingTypeClause)"
|
||||
return readableDatabase.select("COUNT(*)").from(TABLE_NAME).where(where).run().readToSingleInt()
|
||||
}
|
||||
|
||||
fun getArchiveScreenStoriesPage(includeActive: Boolean, sortNewest: Boolean, offset: Int, limit: Int): Reader {
|
||||
val storyClause = if (includeActive) "$STORY_TYPE > 0 AND $DELETED_BY IS NULL" else IS_ARCHIVED_STORY_CLAUSE
|
||||
val where = "$storyClause AND ($outgoingTypeClause)"
|
||||
val order = if (sortNewest) "$TABLE_NAME.$DATE_SENT DESC" else "$TABLE_NAME.$DATE_SENT ASC"
|
||||
return MmsReader(rawQueryWithAttachments(where, null, orderBy = order, limit = limit.toLong(), offset = offset.toLong()))
|
||||
}
|
||||
|
||||
fun getOldestArchivedStorySentTimestamp(): Long? {
|
||||
return readableDatabase
|
||||
.select(DATE_SENT)
|
||||
.from(TABLE_NAME)
|
||||
.where(IS_ARCHIVED_STORY_CLAUSE)
|
||||
.limit(1)
|
||||
.orderBy("$DATE_SENT ASC")
|
||||
.run()
|
||||
.readToSingleObject { it.getLong(0) }
|
||||
}
|
||||
|
||||
fun deleteArchivedStoriesOlderThan(timestamp: Long): Int {
|
||||
return writableDatabase.withinTransaction { db ->
|
||||
val where = "$IS_ARCHIVED_STORY_CLAUSE AND $DATE_SENT < ?"
|
||||
val args = buildArgs(timestamp)
|
||||
|
||||
val deletedCount = db.select(ID)
|
||||
.from(TABLE_NAME)
|
||||
.where(where, args)
|
||||
.run()
|
||||
.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
deleteMessage(cursor.requireLong(ID))
|
||||
}
|
||||
cursor.count
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
OptimizeMessageSearchIndexJob.enqueue()
|
||||
}
|
||||
|
||||
deletedCount
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all the stories received from the recipient in 1:1 stories
|
||||
*/
|
||||
@@ -2057,7 +2121,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
/**
|
||||
* Note: [reverse] and [orderBy] are mutually exclusive. If you want the order to be reversed, explicitly use 'ASC' or 'DESC'
|
||||
*/
|
||||
private fun rawQueryWithAttachments(where: String, arguments: Array<String>?, reverse: Boolean = false, limit: Long = 0, orderBy: String = ""): Cursor {
|
||||
private fun rawQueryWithAttachments(where: String, arguments: Array<String>?, reverse: Boolean = false, limit: Long = 0, offset: Long = 0, orderBy: String = ""): Cursor {
|
||||
val database = databaseHelper.signalReadableDatabase
|
||||
var rawQueryString = """
|
||||
SELECT
|
||||
@@ -2078,6 +2142,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
|
||||
if (limit > 0) {
|
||||
rawQueryString += " LIMIT $limit"
|
||||
if (offset > 0) {
|
||||
rawQueryString += " OFFSET $offset"
|
||||
}
|
||||
}
|
||||
|
||||
return database.rawQuery(rawQueryString, arguments)
|
||||
|
||||
@@ -158,6 +158,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V301_RemoveCallLink
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V302_AddDeletedByColumn
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V303_CaseInsensitiveUsernames
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V304_CallAndReplyNotificationSettings
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V305_AddStoryArchivedColumn
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
|
||||
|
||||
/**
|
||||
@@ -322,10 +323,11 @@ object SignalDatabaseMigrations {
|
||||
301 to V301_RemoveCallLinkEpoch,
|
||||
302 to V302_AddDeletedByColumn,
|
||||
303 to V303_CaseInsensitiveUsernames,
|
||||
304 to V304_CallAndReplyNotificationSettings
|
||||
304 to V304_CallAndReplyNotificationSettings,
|
||||
305 to V305_AddStoryArchivedColumn
|
||||
)
|
||||
|
||||
const val DATABASE_VERSION = 304
|
||||
const val DATABASE_VERSION = 305
|
||||
|
||||
@JvmStatic
|
||||
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Adds a story_archived column to the message table so that outgoing stories
|
||||
* can be preserved in an archive after they leave the 24-hour active feed.
|
||||
*/
|
||||
@Suppress("ClassName")
|
||||
object V305_AddStoryArchivedColumn : SignalDatabaseMigration {
|
||||
|
||||
private val TAG = Log.tag(V305_AddStoryArchivedColumn::class.java)
|
||||
|
||||
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
db.execSQL("ALTER TABLE message ADD COLUMN story_archived INTEGER DEFAULT 0")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS message_story_archived_index ON message (story_archived, story_type, date_sent) WHERE story_type > 0 AND story_archived > 0")
|
||||
Log.i(TAG, "Added story_archived column and index.")
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
|
||||
import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager
|
||||
import org.thoughtcrime.securesms.service.DeletedCallEventManager
|
||||
import org.thoughtcrime.securesms.service.ExpiringArchivedStoriesManager
|
||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager
|
||||
import org.thoughtcrime.securesms.service.ExpiringStoriesManager
|
||||
import org.thoughtcrime.securesms.service.PendingRetryReceiptManager
|
||||
@@ -228,6 +229,11 @@ object AppDependencies {
|
||||
provider.provideExpiringStoriesManager()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
val expireArchivedStoriesManager: ExpiringArchivedStoriesManager by lazy {
|
||||
provider.provideExpiringArchivedStoriesManager()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
val scheduledMessageManager: ScheduledMessageManager by lazy {
|
||||
provider.provideScheduledMessageManager()
|
||||
@@ -438,6 +444,7 @@ object AppDependencies {
|
||||
fun provideTrimThreadsByDateManager(): TrimThreadsByDateManager
|
||||
fun provideViewOnceMessageManager(): ViewOnceMessageManager
|
||||
fun provideExpiringStoriesManager(): ExpiringStoriesManager
|
||||
fun provideExpiringArchivedStoriesManager(): ExpiringArchivedStoriesManager
|
||||
fun provideExpiringMessageManager(): ExpiringMessageManager
|
||||
fun provideDeletedCallEventManager(): DeletedCallEventManager
|
||||
fun provideTypingStatusRepository(): TypingStatusRepository
|
||||
|
||||
@@ -63,6 +63,7 @@ import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipientCache;
|
||||
import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager;
|
||||
import org.thoughtcrime.securesms.service.DeletedCallEventManager;
|
||||
import org.thoughtcrime.securesms.service.ExpiringArchivedStoriesManager;
|
||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
||||
import org.thoughtcrime.securesms.service.ExpiringStoriesManager;
|
||||
import org.thoughtcrime.securesms.service.PendingRetryReceiptManager;
|
||||
@@ -257,6 +258,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
|
||||
return new ExpiringStoriesManager(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ExpiringArchivedStoriesManager provideExpiringArchivedStoriesManager() {
|
||||
return new ExpiringArchivedStoriesManager(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ExpiringMessageManager provideExpiringMessageManager() {
|
||||
return new ExpiringMessageManager(context);
|
||||
|
||||
@@ -4,6 +4,8 @@ import org.json.JSONObject
|
||||
import org.signal.core.util.StringSerializer
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.stories.archive.StoryArchiveDuration
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
|
||||
class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
|
||||
@@ -49,6 +51,9 @@ class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
* Whether or not the user has seen the group story education sheet
|
||||
*/
|
||||
private const val USER_HAS_SEEN_GROUP_STORY_EDUCATION_SHEET = "stories.user.has.seen.group.story.education.sheet"
|
||||
|
||||
private const val ARCHIVE_ENABLED = "stories.archive.enabled"
|
||||
private const val ARCHIVE_DURATION = "stories.archive.duration"
|
||||
}
|
||||
|
||||
public override fun onFirstEverAppLaunch() {
|
||||
@@ -62,7 +67,9 @@ class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
HAS_DOWNLOADED_ONBOARDING_STORY,
|
||||
USER_HAS_VIEWED_ONBOARDING_STORY,
|
||||
STORY_VIEWED_RECEIPTS,
|
||||
USER_HAS_SEEN_GROUP_STORY_EDUCATION_SHEET
|
||||
USER_HAS_SEEN_GROUP_STORY_EDUCATION_SHEET,
|
||||
ARCHIVE_ENABLED,
|
||||
ARCHIVE_DURATION
|
||||
)
|
||||
|
||||
var isFeatureDisabled: Boolean by booleanValue(MANUAL_FEATURE_DISABLE, false)
|
||||
@@ -81,6 +88,18 @@ class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
|
||||
var userHasSeenGroupStoryEducationSheet: Boolean by booleanValue(USER_HAS_SEEN_GROUP_STORY_EDUCATION_SHEET, false)
|
||||
|
||||
var isArchiveEnabled: Boolean
|
||||
get() = RemoteConfig.internalUser && getBoolean(ARCHIVE_ENABLED, false)
|
||||
set(value) {
|
||||
putBoolean(ARCHIVE_ENABLED, value)
|
||||
}
|
||||
|
||||
var archiveDuration: StoryArchiveDuration
|
||||
get() = StoryArchiveDuration.deserialize(getLong(ARCHIVE_DURATION, StoryArchiveDuration.THIRTY_DAYS.serialize()))
|
||||
set(value) {
|
||||
putLong(ARCHIVE_DURATION, value.serialize())
|
||||
}
|
||||
|
||||
fun isViewedReceiptsStateSet(): Boolean {
|
||||
return store.containsKey(STORY_VIEWED_RECEIPTS)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImag
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.rememberRecipientField
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
|
||||
interface MainToolbarCallback {
|
||||
fun onNewGroupClick()
|
||||
@@ -99,6 +100,7 @@ interface MainToolbarCallback {
|
||||
fun onFilterMissedCallsClick()
|
||||
fun onClearCallFilterClick()
|
||||
fun onStoryPrivacyClick()
|
||||
fun onStoryArchiveClick()
|
||||
fun onCloseSearchClick()
|
||||
fun onCloseArchiveClick()
|
||||
fun onCloseActionModeClick()
|
||||
@@ -120,6 +122,7 @@ interface MainToolbarCallback {
|
||||
override fun onFilterMissedCallsClick() = Unit
|
||||
override fun onClearCallFilterClick() = Unit
|
||||
override fun onStoryPrivacyClick() = Unit
|
||||
override fun onStoryArchiveClick() = Unit
|
||||
override fun onCloseSearchClick() = Unit
|
||||
override fun onCloseArchiveClick() = Unit
|
||||
override fun onCloseActionModeClick() = Unit
|
||||
@@ -405,6 +408,17 @@ private fun PrimaryToolbar(
|
||||
NotificationProfileAction(state, callback)
|
||||
ProxyAction(state, callback)
|
||||
|
||||
if (state.destination == MainNavigationListLocation.STORIES && RemoteConfig.internalUser) {
|
||||
IconButtons.IconButton(
|
||||
onClick = callback::onStoryArchiveClick
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_story_archive_24),
|
||||
contentDescription = stringResource(R.string.StoryArchive__story_archive)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButtons.IconButton(
|
||||
onClick = callback::onSearchClick,
|
||||
modifier = Modifier.onPlaced {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.thoughtcrime.securesms.service
|
||||
|
||||
import android.app.Application
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.stories.archive.StoryArchiveDuration
|
||||
|
||||
/**
|
||||
* Manages deleting archived stories after the user-configured retention duration.
|
||||
*/
|
||||
class ExpiringArchivedStoriesManager(
|
||||
application: Application
|
||||
) : TimedEventManager<ExpiringArchivedStoriesManager.Event>(application, "ExpiringArchivedStoriesManager") {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ExpiringArchivedStoriesManager::class.java)
|
||||
}
|
||||
|
||||
init {
|
||||
scheduleIfNecessary()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun getNextClosestEvent(): Event? {
|
||||
if (!SignalStore.story.isArchiveEnabled) return null
|
||||
|
||||
val duration = SignalStore.story.archiveDuration
|
||||
if (duration == StoryArchiveDuration.FOREVER) return null
|
||||
|
||||
val oldestTimestamp = SignalDatabase.messages.getOldestArchivedStorySentTimestamp() ?: return null
|
||||
|
||||
val expirationTime = oldestTimestamp + duration.durationMs
|
||||
val delay = (expirationTime - System.currentTimeMillis()).coerceAtLeast(0)
|
||||
Log.i(TAG, "The oldest archived story needs to be deleted in $delay ms.")
|
||||
|
||||
return Event(delay)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun executeEvent(event: Event) {
|
||||
if (!SignalStore.story.isArchiveEnabled) return
|
||||
|
||||
val duration = SignalStore.story.archiveDuration
|
||||
if (duration == StoryArchiveDuration.FOREVER) return
|
||||
|
||||
val threshold = System.currentTimeMillis() - duration.durationMs
|
||||
val deletes = SignalDatabase.messages.deleteArchivedStoriesOlderThan(threshold)
|
||||
Log.i(TAG, "Deleted $deletes archived stories before $threshold")
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun getDelayForEvent(event: Event): Long = event.delay
|
||||
|
||||
@WorkerThread
|
||||
override fun scheduleAlarm(application: Application, event: Event, delay: Long) {
|
||||
setAlarm(application, delay, ExpireArchivedStoriesAlarm::class.java)
|
||||
}
|
||||
|
||||
data class Event(val delay: Long)
|
||||
|
||||
class ExpireArchivedStoriesAlarm : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ExpireArchivedStoriesAlarm::class.java)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
Log.d(TAG, "onReceive()")
|
||||
AppDependencies.expireArchivedStoriesManager.scheduleIfNecessary()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,19 @@ class ExpiringStoriesManager(
|
||||
@WorkerThread
|
||||
override fun executeEvent(event: Event) {
|
||||
val threshold = System.currentTimeMillis() - STORY_LIFESPAN
|
||||
val deletes = mmsDatabase.deleteStoriesOlderThan(threshold, SignalStore.story.userHasViewedOnboardingStory)
|
||||
val hasSeenOnboarding = SignalStore.story.userHasViewedOnboardingStory
|
||||
|
||||
if (SignalStore.story.isArchiveEnabled) {
|
||||
val archived = mmsDatabase.archiveStoriesOlderThan(threshold, hasSeenOnboarding)
|
||||
Log.i(TAG, "Archived $archived outgoing stories before $threshold")
|
||||
}
|
||||
|
||||
val deletes = mmsDatabase.deleteUnarchivedStoriesOlderThan(threshold, hasSeenOnboarding)
|
||||
Log.i(TAG, "Deleted $deletes stories before $threshold")
|
||||
|
||||
if (SignalStore.story.isArchiveEnabled) {
|
||||
AppDependencies.expireArchivedStoriesManager.scheduleIfNecessary()
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
||||
@@ -23,7 +23,8 @@ data class StoryViewerArgs(
|
||||
val isFromInfoContextMenuAction: Boolean = false,
|
||||
val isFromQuote: Boolean = false,
|
||||
val isFromMyStories: Boolean = false,
|
||||
val isJumpToUnviewed: Boolean = false
|
||||
val isJumpToUnviewed: Boolean = false,
|
||||
val isFromArchive: Boolean = false
|
||||
) : Parcelable {
|
||||
|
||||
class Builder(private val recipientId: RecipientId, private val isInHiddenStoryMode: Boolean) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.thoughtcrime.securesms.stories.archive
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
|
||||
class StoryArchiveActivity : PassphraseRequiredActivity() {
|
||||
|
||||
companion object {
|
||||
fun createIntent(context: Context): Intent {
|
||||
return Intent(context, StoryArchiveActivity::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
|
||||
setContent {
|
||||
SignalTheme {
|
||||
StoryArchiveScreen(
|
||||
onNavigationClick = { onBackPressedDispatcher.onBackPressed() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.thoughtcrime.securesms.stories.archive
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.thoughtcrime.securesms.R
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
enum class StoryArchiveDuration(val durationMs: Long, @StringRes val labelRes: Int) {
|
||||
FOREVER(-1L, R.string.StoryArchive__forever),
|
||||
ONE_YEAR(365.days.inWholeMilliseconds, R.string.StoryArchive__1_year),
|
||||
SIX_MONTHS(182.days.inWholeMilliseconds, R.string.StoryArchive__6_months),
|
||||
THIRTY_DAYS(30.days.inWholeMilliseconds, R.string.StoryArchive__30_days);
|
||||
|
||||
fun serialize(): Long = durationMs
|
||||
|
||||
companion object {
|
||||
fun deserialize(value: Long): StoryArchiveDuration {
|
||||
return entries.firstOrNull { it.durationMs == value } ?: throw IllegalArgumentException("Unknown value: $value")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.thoughtcrime.securesms.stories.archive
|
||||
|
||||
import org.signal.paging.PagedDataSource
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
class StoryArchivePagedDataSource(
|
||||
private val sortNewest: Boolean
|
||||
) : PagedDataSource<Long, ArchivedStoryItem> {
|
||||
|
||||
private val includeActive = SignalStore.story.isArchiveEnabled
|
||||
|
||||
override fun size(): Int {
|
||||
return SignalDatabase.messages.getArchiveScreenStoriesCount(includeActive)
|
||||
}
|
||||
|
||||
override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): List<ArchivedStoryItem> {
|
||||
return SignalDatabase.messages.getArchiveScreenStoriesPage(includeActive, sortNewest, start, length).use { reader ->
|
||||
reader.mapNotNull { record ->
|
||||
if (cancellationSignal.isCanceled) return@use emptyList()
|
||||
val mmsRecord = record as? MmsMessageRecord
|
||||
ArchivedStoryItem(
|
||||
messageId = record.id,
|
||||
dateSent = record.dateSent,
|
||||
thumbnailUri = mmsRecord?.slideDeck?.thumbnailSlide?.uri,
|
||||
blurHash = mmsRecord?.slideDeck?.thumbnailSlide?.placeholderBlur,
|
||||
storyType = mmsRecord?.storyType ?: StoryType.NONE,
|
||||
body = record.body
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun load(key: Long): ArchivedStoryItem? {
|
||||
val record = SignalDatabase.messages.getMessageRecordOrNull(key) ?: return null
|
||||
val mmsRecord = record as? MmsMessageRecord
|
||||
return ArchivedStoryItem(
|
||||
messageId = record.id,
|
||||
dateSent = record.dateSent,
|
||||
thumbnailUri = mmsRecord?.slideDeck?.thumbnailSlide?.uri,
|
||||
blurHash = mmsRecord?.slideDeck?.thumbnailSlide?.placeholderBlur,
|
||||
storyType = mmsRecord?.storyType ?: StoryType.NONE,
|
||||
body = record.body
|
||||
)
|
||||
}
|
||||
|
||||
override fun getKey(data: ArchivedStoryItem): Long {
|
||||
return data.messageId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.thoughtcrime.securesms.stories.archive
|
||||
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSyncJob
|
||||
|
||||
class StoryArchiveRepository {
|
||||
|
||||
fun deleteStories(messageIds: Set<Long>) {
|
||||
val records = messageIds.mapNotNull { SignalDatabase.messages.getMessageRecordOrNull(it) }.toSet()
|
||||
messageIds.forEach { SignalDatabase.messages.deleteMessage(it) }
|
||||
MultiDeviceDeleteSyncJob.enqueueMessageDeletes(records)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
package org.thoughtcrime.securesms.stories.archive
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.bumptech.glide.Glide
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.DropdownMenus
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.glide.decryptableuri.DecryptableUri
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.stories.StoryTextPostModel
|
||||
import org.thoughtcrime.securesms.stories.StoryViewerArgs
|
||||
import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity
|
||||
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
@Composable
|
||||
fun StoryArchiveScreen(
|
||||
onNavigationClick: () -> Unit,
|
||||
viewModel: StoryArchiveViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
LifecycleResumeEffect(Unit) {
|
||||
viewModel.refresh()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
BackHandler(enabled = state.multiSelectEnabled) {
|
||||
viewModel.clearSelection()
|
||||
}
|
||||
|
||||
val sortMenuController = remember { DropdownMenus.MenuController() }
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = if (state.multiSelectEnabled) {
|
||||
pluralStringResource(R.plurals.StoryArchive__d_selected, state.selectedIds.size, state.selectedIds.size)
|
||||
} else {
|
||||
stringResource(R.string.StoryArchive__story_archive) + " (Internal Only)"
|
||||
},
|
||||
onNavigationClick = if (state.multiSelectEnabled) {
|
||||
{ viewModel.clearSelection() }
|
||||
} else {
|
||||
onNavigationClick
|
||||
},
|
||||
navigationIcon = if (state.multiSelectEnabled) SignalIcons.X.imageVector else null,
|
||||
actions = {
|
||||
if (!state.multiSelectEnabled) {
|
||||
Box {
|
||||
IconButton(onClick = { sortMenuController.show() }) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_more_vertical),
|
||||
contentDescription = stringResource(R.string.StoryArchive__sort_by)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenus.Menu(
|
||||
controller = sortMenuController,
|
||||
offsetX = 0.dp
|
||||
) {
|
||||
DropdownMenus.Item(
|
||||
text = { Text(text = stringResource(R.string.StoryArchive__newest)) },
|
||||
onClick = {
|
||||
viewModel.setSortOrder(SortOrder.NEWEST)
|
||||
sortMenuController.hide()
|
||||
}
|
||||
)
|
||||
DropdownMenus.Item(
|
||||
text = { Text(text = stringResource(R.string.StoryArchive__oldest)) },
|
||||
onClick = {
|
||||
viewModel.setSortOrder(SortOrder.OLDEST)
|
||||
sortMenuController.hide()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
StoryArchiveContent(
|
||||
state = state,
|
||||
pagingController = viewModel.pagingController,
|
||||
onToggleSelection = { messageId -> viewModel.toggleSelection(messageId) },
|
||||
onDeleteClick = { viewModel.requestDeleteSelected() },
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
|
||||
if (state.showDeleteConfirmation) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = Dialogs.NoTitle,
|
||||
body = pluralStringResource(R.plurals.StoryArchive__delete_n_stories, state.selectedIds.size, state.selectedIds.size),
|
||||
confirm = stringResource(R.string.StoryArchive__delete),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = { viewModel.confirmDeleteSelected() },
|
||||
onDeny = { viewModel.cancelDelete() },
|
||||
onDismiss = { viewModel.cancelDelete() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StoryArchiveContent(
|
||||
state: StoryArchiveState,
|
||||
pagingController: org.signal.paging.PagingController<Long>,
|
||||
onToggleSelection: (Long) -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
when {
|
||||
state.isLoading -> {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
state.stories.isEmpty() -> {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.StoryArchive__no_archived_stories),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.StoryArchive__no_archived_stories_message),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp)
|
||||
)
|
||||
Buttons.Small(
|
||||
onClick = { context.startActivity(StorySettingsActivity.getIntent(context)) },
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
shape = RoundedCornerShape(100),
|
||||
elevation = null,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.StoryArchive__go_to_settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
StoryArchiveGrid(
|
||||
stories = state.stories,
|
||||
multiSelectEnabled = state.multiSelectEnabled,
|
||||
selectedIds = state.selectedIds,
|
||||
pagingController = pagingController,
|
||||
onToggleSelection = onToggleSelection,
|
||||
onDeleteClick = onDeleteClick,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StoryArchiveGrid(
|
||||
stories: List<ArchivedStoryItem?>,
|
||||
multiSelectEnabled: Boolean,
|
||||
selectedIds: Set<Long>,
|
||||
pagingController: org.signal.paging.PagingController<Long>,
|
||||
onToggleSelection: (Long) -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = LocalActivity.current
|
||||
val density = LocalDensity.current
|
||||
var bottomActionBarPadding by remember { mutableStateOf(0.dp) }
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
contentPadding = PaddingValues(bottom = bottomActionBarPadding),
|
||||
modifier = Modifier.padding(horizontal = 2.dp)
|
||||
) {
|
||||
items(
|
||||
count = stories.size,
|
||||
key = { index -> stories[index]?.messageId ?: -index.toLong() }
|
||||
) { index ->
|
||||
LaunchedEffect(index) {
|
||||
pagingController.onDataNeededAroundIndex(index)
|
||||
}
|
||||
|
||||
val item = stories[index]
|
||||
if (item == null) {
|
||||
StoryArchivePlaceholder()
|
||||
} else {
|
||||
val isFirstOfDate = computeDateIndicator(stories, index)
|
||||
|
||||
StoryArchiveTile(
|
||||
item = item,
|
||||
showDateIndicator = isFirstOfDate,
|
||||
isSelected = selectedIds.contains(item.messageId),
|
||||
onClick = { view ->
|
||||
if (multiSelectEnabled) {
|
||||
onToggleSelection(item.messageId)
|
||||
} else {
|
||||
val textModel = if (item.storyType.isTextStory && item.body != null) {
|
||||
StoryTextPostModel.parseFrom(
|
||||
body = item.body,
|
||||
storySentAtMillis = item.dateSent,
|
||||
storyAuthor = Recipient.self().id,
|
||||
bodyRanges = null
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val args = StoryViewerArgs(
|
||||
recipientId = Recipient.self().id,
|
||||
isInHiddenStoryMode = false,
|
||||
storyId = item.messageId,
|
||||
isFromArchive = true,
|
||||
storyThumbTextModel = textModel,
|
||||
storyThumbUri = if (textModel == null) item.thumbnailUri else null,
|
||||
storyThumbBlur = if (textModel == null) item.blurHash else null
|
||||
)
|
||||
val intent = StoryViewerActivity.createIntent(context, args)
|
||||
if (view != null && activity != null) {
|
||||
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, view, ViewCompat.getTransitionName(view) ?: "")
|
||||
context.startActivity(intent, options.toBundle())
|
||||
} else {
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = { onToggleSelection(item.messageId) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SignalBottomActionBar(
|
||||
visible = multiSelectEnabled,
|
||||
items = listOf(
|
||||
ActionItem(
|
||||
iconRes = CoreUiR.drawable.symbol_trash_24,
|
||||
title = stringResource(R.string.StoryArchive__delete),
|
||||
action = { onDeleteClick() }
|
||||
)
|
||||
),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.onGloballyPositioned { layoutCoordinates ->
|
||||
bottomActionBarPadding = with(density) { layoutCoordinates.size.height.toDp() }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StoryArchivePlaceholder() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(9f / 16f)
|
||||
.padding(1.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun StoryArchiveTile(
|
||||
item: ArchivedStoryItem,
|
||||
showDateIndicator: Boolean,
|
||||
isSelected: Boolean,
|
||||
onClick: (View?) -> Unit,
|
||||
onLongClick: () -> Unit
|
||||
) {
|
||||
var imageViewRef by remember { mutableStateOf<View?>(null) }
|
||||
val haptics = LocalHapticFeedback.current
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(9f / 16f)
|
||||
.padding(1.dp)
|
||||
.combinedClickable(
|
||||
onClick = { onClick(imageViewRef) },
|
||||
onLongClick = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onLongClick()
|
||||
},
|
||||
onLongClickLabel = stringResource(R.string.StoryArchive__select_story)
|
||||
)
|
||||
) {
|
||||
if (item.storyType.isTextStory && item.body != null) {
|
||||
val textModel = StoryTextPostModel.parseFrom(
|
||||
body = item.body,
|
||||
storySentAtMillis = item.dateSent,
|
||||
storyAuthor = Recipient.self().id,
|
||||
bodyRanges = null
|
||||
)
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
ImageView(context).apply {
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
ViewCompat.setTransitionName(this, "story")
|
||||
}.also { imageViewRef = it }
|
||||
},
|
||||
update = { iv ->
|
||||
Glide.with(iv).load(textModel).centerCrop().into(iv)
|
||||
},
|
||||
onReset = { Glide.with(it).clear(it) },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else if (item.thumbnailUri != null) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
ImageView(context).apply {
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
ViewCompat.setTransitionName(this, "story")
|
||||
}.also { imageViewRef = it }
|
||||
},
|
||||
update = { iv ->
|
||||
Glide.with(iv).load(DecryptableUri(item.thumbnailUri)).centerCrop().into(iv)
|
||||
},
|
||||
onReset = { Glide.with(it).clear(it) },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
)
|
||||
}
|
||||
|
||||
if (showDateIndicator) {
|
||||
val (day, month) = remember(item.dateSent) { formatDateIndicator(item.dateSent) }
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(4.dp)
|
||||
.defaultMinSize(40.dp)
|
||||
.background(
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = day,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color.Black,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = month,
|
||||
fontSize = 10.sp,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Black,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.4f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = SignalIcons.CheckCircle.imageVector,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeDateIndicator(stories: List<ArchivedStoryItem?>, index: Int): Boolean {
|
||||
val item = stories[index] ?: return false
|
||||
if (index == 0) return true
|
||||
val prev = stories[index - 1] ?: return true
|
||||
val cal = Calendar.getInstance()
|
||||
cal.timeInMillis = item.dateSent
|
||||
val year = cal.get(Calendar.YEAR)
|
||||
val day = cal.get(Calendar.DAY_OF_YEAR)
|
||||
cal.timeInMillis = prev.dateSent
|
||||
return year != cal.get(Calendar.YEAR) || day != cal.get(Calendar.DAY_OF_YEAR)
|
||||
}
|
||||
|
||||
private fun formatDateIndicator(timestamp: Long): Pair<String, String> {
|
||||
val date = Date(timestamp)
|
||||
val day = SimpleDateFormat("d", Locale.getDefault()).format(date)
|
||||
val month = SimpleDateFormat("MMM", Locale.getDefault()).format(date)
|
||||
return day to month
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun StoryArchiveLoadingPreview() {
|
||||
Previews.Preview {
|
||||
StoryArchiveContent(
|
||||
state = StoryArchiveState(isLoading = true),
|
||||
pagingController = NoOpPagingController,
|
||||
onToggleSelection = {},
|
||||
onDeleteClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun StoryArchiveEmptyPreview() {
|
||||
Previews.Preview {
|
||||
StoryArchiveContent(
|
||||
state = StoryArchiveState(isLoading = false, stories = emptyList()),
|
||||
pagingController = NoOpPagingController,
|
||||
onToggleSelection = {},
|
||||
onDeleteClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun StoryArchiveTilePreview() {
|
||||
Previews.Preview {
|
||||
Box(modifier = Modifier.size(120.dp, 213.dp)) {
|
||||
StoryArchiveTile(
|
||||
item = ArchivedStoryItem(
|
||||
messageId = 1L,
|
||||
dateSent = System.currentTimeMillis(),
|
||||
thumbnailUri = null,
|
||||
blurHash = null,
|
||||
storyType = StoryType.NONE,
|
||||
body = null
|
||||
),
|
||||
showDateIndicator = true,
|
||||
isSelected = false,
|
||||
onClick = { _ -> },
|
||||
onLongClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun StoryArchiveSelectedTilePreview() {
|
||||
Previews.Preview {
|
||||
Box(modifier = Modifier.size(120.dp, 213.dp)) {
|
||||
StoryArchiveTile(
|
||||
item = ArchivedStoryItem(
|
||||
messageId = 1L,
|
||||
dateSent = System.currentTimeMillis(),
|
||||
thumbnailUri = null,
|
||||
blurHash = null,
|
||||
storyType = StoryType.NONE,
|
||||
body = null
|
||||
),
|
||||
showDateIndicator = false,
|
||||
isSelected = true,
|
||||
onClick = { _ -> },
|
||||
onLongClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object NoOpPagingController : org.signal.paging.PagingController<Long> {
|
||||
override fun onDataNeededAroundIndex(aroundIndex: Int) = Unit
|
||||
override fun onDataInvalidated() = Unit
|
||||
override fun onDataItemChanged(key: Long) = Unit
|
||||
override fun onDataItemInserted(key: Long, position: Int) = Unit
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.thoughtcrime.securesms.stories.archive
|
||||
|
||||
import android.net.Uri
|
||||
import org.signal.blurhash.BlurHash
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
|
||||
data class StoryArchiveState(
|
||||
val stories: List<ArchivedStoryItem?> = emptyList(),
|
||||
val sortOrder: SortOrder = SortOrder.NEWEST,
|
||||
val isLoading: Boolean = true,
|
||||
val multiSelectEnabled: Boolean = false,
|
||||
val selectedIds: Set<Long> = emptySet(),
|
||||
val showDeleteConfirmation: Boolean = false
|
||||
)
|
||||
|
||||
enum class SortOrder { NEWEST, OLDEST }
|
||||
|
||||
data class ArchivedStoryItem(
|
||||
val messageId: Long,
|
||||
val dateSent: Long,
|
||||
val thumbnailUri: Uri?,
|
||||
val blurHash: BlurHash?,
|
||||
val storyType: StoryType,
|
||||
val body: String?
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
package org.thoughtcrime.securesms.stories.archive
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.paging.PagedData
|
||||
import org.signal.paging.PagingConfig
|
||||
import org.signal.paging.PagingController
|
||||
import org.signal.paging.ProxyPagingController
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
|
||||
class StoryArchiveViewModel : ViewModel() {
|
||||
|
||||
private val repository = StoryArchiveRepository()
|
||||
|
||||
private val _state = MutableStateFlow(StoryArchiveState())
|
||||
val state: StateFlow<StoryArchiveState> = _state
|
||||
|
||||
private var pagedDataJob: Job? = null
|
||||
private val proxyPagingController = ProxyPagingController<Long>()
|
||||
val pagingController: PagingController<Long> get() = proxyPagingController
|
||||
|
||||
private val databaseObserver = DatabaseObserver.Observer {
|
||||
proxyPagingController.onDataInvalidated()
|
||||
}
|
||||
|
||||
init {
|
||||
AppDependencies.databaseObserver.registerConversationListObserver(databaseObserver)
|
||||
loadPagedData()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
loadPagedData()
|
||||
}
|
||||
|
||||
fun setSortOrder(sortOrder: SortOrder) {
|
||||
_state.value = _state.value.copy(sortOrder = sortOrder, isLoading = true)
|
||||
loadPagedData()
|
||||
}
|
||||
|
||||
fun toggleSelection(messageId: Long) {
|
||||
val current = _state.value
|
||||
val newSelection = if (current.selectedIds.contains(messageId)) {
|
||||
current.selectedIds - messageId
|
||||
} else {
|
||||
current.selectedIds + messageId
|
||||
}
|
||||
_state.value = current.copy(
|
||||
selectedIds = newSelection,
|
||||
multiSelectEnabled = newSelection.isNotEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
_state.value = _state.value.copy(
|
||||
multiSelectEnabled = false,
|
||||
selectedIds = emptySet(),
|
||||
showDeleteConfirmation = false
|
||||
)
|
||||
}
|
||||
|
||||
fun requestDeleteSelected() {
|
||||
_state.value = _state.value.copy(showDeleteConfirmation = true)
|
||||
}
|
||||
|
||||
fun cancelDelete() {
|
||||
_state.value = _state.value.copy(showDeleteConfirmation = false)
|
||||
}
|
||||
|
||||
fun confirmDeleteSelected() {
|
||||
val idsToDelete = _state.value.selectedIds
|
||||
_state.value = _state.value.copy(
|
||||
multiSelectEnabled = false,
|
||||
selectedIds = emptySet(),
|
||||
showDeleteConfirmation = false
|
||||
)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
repository.deleteStories(idsToDelete)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPagedData() {
|
||||
val sortNewest = _state.value.sortOrder == SortOrder.NEWEST
|
||||
val dataSource = StoryArchivePagedDataSource(sortNewest)
|
||||
val config = PagingConfig.Builder()
|
||||
.setPageSize(30)
|
||||
.setBufferPages(1)
|
||||
.setStartIndex(0)
|
||||
.build()
|
||||
|
||||
val newPagedData = PagedData.createForStateFlow(dataSource, config)
|
||||
proxyPagingController.set(newPagedData.controller)
|
||||
|
||||
pagedDataJob?.cancel()
|
||||
pagedDataJob = viewModelScope.launch {
|
||||
newPagedData.data.collectLatest { stories ->
|
||||
_state.value = _state.value.copy(stories = stories, isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
AppDependencies.databaseObserver.unregisterObserver(databaseObserver)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.ui.BottomSheetUtil
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.dp
|
||||
@@ -22,9 +23,11 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomSheet
|
||||
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet
|
||||
import org.thoughtcrime.securesms.stories.GroupStoryEducationSheet
|
||||
import org.thoughtcrime.securesms.stories.archive.StoryArchiveDuration
|
||||
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
|
||||
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryFlowDialogFragment
|
||||
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryWithViewersFragment
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -167,6 +170,42 @@ class StoriesPrivacySettingsFragment :
|
||||
}
|
||||
)
|
||||
|
||||
if (RemoteConfig.internalUser) {
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.StoryArchive__archive)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.StoryArchive__keep_stories_in_archive),
|
||||
summary = DSLSettingsText.from(R.string.StoryArchive__save_stories_after_they_expire),
|
||||
isChecked = state.isArchiveEnabled,
|
||||
onClick = {
|
||||
viewModel.toggleArchiveEnabled()
|
||||
}
|
||||
)
|
||||
|
||||
if (state.isArchiveEnabled) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.StoryArchive__keep_stories_for),
|
||||
summary = DSLSettingsText.from(state.archiveDuration.labelRes),
|
||||
onClick = {
|
||||
val durations = StoryArchiveDuration.entries.toTypedArray()
|
||||
val labels = durations.map { getString(it.labelRes) }.toTypedArray()
|
||||
val checkedIndex = durations.indexOf(state.archiveDuration)
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.StoryArchive__keep_stories_for)
|
||||
.setSingleChoiceItems(labels, checkedIndex) { dialog, which ->
|
||||
viewModel.setArchiveDuration(durations[which])
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dividerPref()
|
||||
|
||||
clickPref(
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package org.thoughtcrime.securesms.stories.settings.story
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
|
||||
import org.thoughtcrime.securesms.stories.archive.StoryArchiveDuration
|
||||
|
||||
data class StoriesPrivacySettingsState(
|
||||
val areStoriesEnabled: Boolean,
|
||||
val areViewReceiptsEnabled: Boolean,
|
||||
val isUpdatingEnabledState: Boolean = false,
|
||||
val storyContactItems: List<ContactSearchData> = emptyList(),
|
||||
val userHasStories: Boolean = false
|
||||
val userHasStories: Boolean = false,
|
||||
val isArchiveEnabled: Boolean = false,
|
||||
val archiveDuration: StoryArchiveDuration = StoryArchiveDuration.THIRTY_DAYS
|
||||
)
|
||||
|
||||
@@ -14,9 +14,11 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSource
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.stories.archive.StoryArchiveDuration
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
|
||||
class StoriesPrivacySettingsViewModel(
|
||||
@@ -28,7 +30,9 @@ class StoriesPrivacySettingsViewModel(
|
||||
private val store = RxStore(
|
||||
StoriesPrivacySettingsState(
|
||||
areStoriesEnabled = Stories.isFeatureEnabled(),
|
||||
areViewReceiptsEnabled = SignalStore.story.viewedReceiptsEnabled
|
||||
areViewReceiptsEnabled = SignalStore.story.viewedReceiptsEnabled,
|
||||
isArchiveEnabled = SignalStore.story.isArchiveEnabled,
|
||||
archiveDuration = SignalStore.story.archiveDuration
|
||||
)
|
||||
)
|
||||
|
||||
@@ -89,6 +93,24 @@ class StoriesPrivacySettingsViewModel(
|
||||
repository.onSettingsChanged()
|
||||
}
|
||||
|
||||
fun toggleArchiveEnabled() {
|
||||
SignalStore.story.isArchiveEnabled = !SignalStore.story.isArchiveEnabled
|
||||
store.update {
|
||||
it.copy(isArchiveEnabled = SignalStore.story.isArchiveEnabled)
|
||||
}
|
||||
if (SignalStore.story.isArchiveEnabled) {
|
||||
AppDependencies.expireArchivedStoriesManager.scheduleIfNecessary()
|
||||
}
|
||||
}
|
||||
|
||||
fun setArchiveDuration(duration: StoryArchiveDuration) {
|
||||
SignalStore.story.archiveDuration = duration
|
||||
store.update {
|
||||
it.copy(archiveDuration = duration)
|
||||
}
|
||||
AppDependencies.expireArchivedStoriesManager.scheduleIfNecessary()
|
||||
}
|
||||
|
||||
fun displayGroupsAsStories(recipientIds: List<RecipientId>) {
|
||||
disposables += repository.markGroupsAsStories(recipientIds).subscribe {
|
||||
pagingController.onDataInvalidated()
|
||||
|
||||
@@ -74,7 +74,8 @@ class StoryViewerFragment :
|
||||
storyViewerArgs.isFromNotification -> StoryViewerPageArgs.Source.NOTIFICATION
|
||||
else -> StoryViewerPageArgs.Source.UNKNOWN
|
||||
},
|
||||
groupReplyStartPosition = storyViewerArgs.groupReplyStartPosition
|
||||
groupReplyStartPosition = storyViewerArgs.groupReplyStartPosition,
|
||||
isFromArchive = storyViewerArgs.isFromArchive
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ class StoryViewerViewModel(
|
||||
storyViewerArgs.storyThumbUri != null -> StoryViewerState.CrossfadeSource.ImageUri(storyViewerArgs.storyThumbUri, storyViewerArgs.storyThumbBlur)
|
||||
else -> StoryViewerState.CrossfadeSource.None
|
||||
},
|
||||
skipCrossfade = storyViewerArgs.isFromNotification || storyViewerArgs.isFromQuote
|
||||
skipCrossfade = storyViewerArgs.isFromNotification ||
|
||||
storyViewerArgs.isFromQuote ||
|
||||
(storyViewerArgs.isFromArchive && storyViewerArgs.storyThumbTextModel == null && storyViewerArgs.storyThumbUri == null)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -128,7 +130,9 @@ class StoryViewerViewModel(
|
||||
}
|
||||
|
||||
private fun getStories(): Single<List<RecipientId>> {
|
||||
return if (storyViewerArgs.recipientIds.isNotEmpty()) {
|
||||
return if (storyViewerArgs.isFromArchive) {
|
||||
Single.just(listOf(storyViewerArgs.recipientId))
|
||||
} else if (storyViewerArgs.recipientIds.isNotEmpty()) {
|
||||
Single.just(storyViewerArgs.recipientIds - hidden)
|
||||
} else {
|
||||
repository.getStories(
|
||||
|
||||
@@ -11,7 +11,8 @@ data class StoryViewerPageArgs(
|
||||
val isOutgoingOnly: Boolean,
|
||||
val isJumpForwardToUnviewed: Boolean,
|
||||
val source: Source,
|
||||
val groupReplyStartPosition: Int
|
||||
val groupReplyStartPosition: Int,
|
||||
val isFromArchive: Boolean = false
|
||||
) : Parcelable {
|
||||
enum class Source {
|
||||
UNKNOWN,
|
||||
|
||||
@@ -151,10 +151,22 @@ open class StoryViewerPageRepository(context: Context, private val storyViewStat
|
||||
return Stories.enqueueAttachmentsFromStoryForDownload(post.conversationMessage.messageRecord as MmsMessageRecord, true)
|
||||
}
|
||||
|
||||
fun getStoryPostsFor(recipientId: RecipientId, isOutgoingOnly: Boolean): Observable<List<StoryPost>> {
|
||||
return getStoryRecords(recipientId, isOutgoingOnly)
|
||||
.switchMap { records ->
|
||||
val posts: List<Observable<StoryPost>> = records.map {
|
||||
fun getStoryPostsFor(recipientId: RecipientId, isOutgoingOnly: Boolean, isFromArchive: Boolean = false, initialStoryId: Long = -1L): Observable<List<StoryPost>> {
|
||||
val records = if (isFromArchive && initialStoryId > 0) {
|
||||
Observable.fromCallable {
|
||||
try {
|
||||
listOf(SignalDatabase.messages.getMessageRecord(initialStoryId))
|
||||
} catch (e: NoSuchMessageException) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getStoryRecords(recipientId, isOutgoingOnly)
|
||||
}
|
||||
|
||||
return records
|
||||
.switchMap { recordList ->
|
||||
val posts: List<Observable<StoryPost>> = recordList.map {
|
||||
getStoryPostFromRecord(recipientId, it).distinctUntilChanged()
|
||||
}
|
||||
if (posts.isEmpty()) {
|
||||
|
||||
@@ -71,7 +71,7 @@ class StoryViewerPageViewModel(
|
||||
|
||||
fun refresh() {
|
||||
disposables.clear()
|
||||
disposables += repository.getStoryPostsFor(args.recipientId, args.isOutgoingOnly)
|
||||
disposables += repository.getStoryPostsFor(args.recipientId, args.isOutgoingOnly, args.isFromArchive, args.initialStoryId)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { posts ->
|
||||
|
||||
Reference in New Issue
Block a user