mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-02 08:23:00 +01:00
Add support for story archiving.
This commit is contained in:
committed by
jeffrey-signal
parent
ff50755ba2
commit
e7d1db446b
@@ -535,6 +535,12 @@
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".stories.archive.StoryArchiveActivity"
|
||||
android:exported="false"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity
|
||||
android:name=".stories.viewer.StoryViewerActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
@@ -1430,6 +1436,10 @@
|
||||
android:name=".service.ExpiringStoriesManager$ExpireStoriesAlarm"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".service.ExpiringArchivedStoriesManager$ExpireArchivedStoriesAlarm"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".revealable.ViewOnceMessageManager$ViewOnceAlarm"
|
||||
android:exported="false" />
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
15
app/src/main/res/drawable/symbol_story_archive_24.xml
Normal file
15
app/src/main/res/drawable/symbol_story_archive_24.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0,0h24v24h-24z"/>
|
||||
<path
|
||||
android:pathData="M0.966,10.329H2.386C3.313,5.615 7.523,2 12.478,2C18.087,2 22.75,6.653 22.75,12.273C22.75,17.882 18.087,22.545 12.478,22.545C9.084,22.545 6.082,20.813 4.169,18.245C3.827,17.802 3.937,17.247 4.34,16.996C4.763,16.744 5.237,16.875 5.549,17.288C7.09,19.433 9.607,20.823 12.478,20.833C17.231,20.853 21.038,17.026 21.038,12.273C21.038,7.519 17.231,3.722 12.478,3.722C8.389,3.722 5.005,6.522 4.129,10.329H5.589C6.304,10.329 6.485,10.822 6.103,11.376L3.847,14.589C3.524,15.042 3.041,15.052 2.709,14.589L0.453,11.386C0.06,10.822 0.241,10.329 0.966,10.329ZM12.095,6.371C12.538,6.371 12.891,6.723 12.891,7.166V12.514L15.328,15.797C15.63,16.21 15.6,16.684 15.227,16.945C14.814,17.227 14.351,17.157 14.018,16.724L11.531,13.441C11.36,13.219 11.289,13.018 11.289,12.786V7.166C11.289,6.723 11.652,6.371 12.095,6.371Z"
|
||||
android:strokeWidth="0.1"
|
||||
android:fillColor="#000000"
|
||||
android:strokeColor="#000000"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -6798,6 +6798,53 @@
|
||||
<string name="MyStories__this_story_will_be_deleted">This story will be deleted for you and everyone who received it.</string>
|
||||
<!-- Toast shown when story media cannot be saved -->
|
||||
<string name="MyStories__unable_to_save">Unable to save</string>
|
||||
|
||||
<!-- StoryArchive -->
|
||||
<!-- Title for the story archive screen -->
|
||||
<string name="StoryArchive__story_archive">Story archive</string>
|
||||
<!-- Section header in story settings -->
|
||||
<string name="StoryArchive__archive">Archive</string>
|
||||
<!-- Label for switch to enable story archiving -->
|
||||
<string name="StoryArchive__keep_stories_in_archive">Keep stories in archive</string>
|
||||
<!-- Description for the archive toggle -->
|
||||
<string name="StoryArchive__save_stories_after_they_expire">Save your sent stories after they leave the active feed.</string>
|
||||
<!-- Label for archive duration preference -->
|
||||
<string name="StoryArchive__keep_stories_for">Keep stories for</string>
|
||||
<!-- Archive duration option: forever -->
|
||||
<string name="StoryArchive__forever">Forever</string>
|
||||
<!-- Archive duration option: 1 year -->
|
||||
<string name="StoryArchive__1_year">1 year</string>
|
||||
<!-- Archive duration option: 6 months -->
|
||||
<string name="StoryArchive__6_months">6 months</string>
|
||||
<!-- Archive duration option: 30 days -->
|
||||
<string name="StoryArchive__30_days">30 days</string>
|
||||
<!-- Empty state title when no archived stories exist -->
|
||||
<string name="StoryArchive__no_archived_stories">No archived stories</string>
|
||||
<!-- Empty state message when no archived stories exist -->
|
||||
<string name="StoryArchive__no_archived_stories_message">Turn on \"Save Stories to Archive\" in story settings to auto-archive your stories.</string>
|
||||
<!-- Empty state button to navigate to story settings -->
|
||||
<string name="StoryArchive__go_to_settings">Go to settings</string>
|
||||
<!-- Label for sort order menu -->
|
||||
<string name="StoryArchive__sort_by">Sort by</string>
|
||||
<!-- Sort order option: newest first -->
|
||||
<string name="StoryArchive__newest">Newest</string>
|
||||
<!-- Sort order option: oldest first -->
|
||||
<string name="StoryArchive__oldest">Oldest</string>
|
||||
<!-- Delete action in story archive multi-select bottom bar -->
|
||||
<string name="StoryArchive__delete">Delete</string>
|
||||
<!-- Content description for selecting a story in multi-select mode -->
|
||||
<string name="StoryArchive__select_story">Select story</string>
|
||||
<!-- Confirmation dialog body when deleting stories from archive -->
|
||||
<plurals name="StoryArchive__delete_n_stories">
|
||||
<item quantity="one">Delete %d story? This cannot be undone.</item>
|
||||
<item quantity="other">Delete %d stories? This cannot be undone.</item>
|
||||
</plurals>
|
||||
<!-- Title shown in toolbar when in multi-select mode in story archive, %d is count of selected items -->
|
||||
<plurals name="StoryArchive__d_selected">
|
||||
<item quantity="one">%d selected</item>
|
||||
<item quantity="other">%d selected</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Displayed at bottom of story viewer when current item has views -->
|
||||
<plurals name="StoryViewerFragment__d_views">
|
||||
<item quantity="one">%1$d view</item>
|
||||
|
||||
@@ -20,6 +20,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
|
||||
@@ -135,6 +136,10 @@ class MockApplicationDependencyProvider : AppDependencies.Provider {
|
||||
return mockk(relaxed = true)
|
||||
}
|
||||
|
||||
override fun provideExpiringArchivedStoriesManager(): ExpiringArchivedStoriesManager {
|
||||
return mockk(relaxed = true)
|
||||
}
|
||||
|
||||
override fun provideExpiringMessageManager(): ExpiringMessageManager {
|
||||
return mockk(relaxed = true)
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:util"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject;
|
||||
import io.reactivex.rxjava3.subjects.Subject;
|
||||
import kotlinx.coroutines.flow.MutableStateFlow;
|
||||
import kotlinx.coroutines.flow.StateFlow;
|
||||
|
||||
/**
|
||||
* The primary entry point for creating paged data.
|
||||
@@ -36,6 +38,14 @@ public class PagedData<Key> {
|
||||
return new ObservablePagedData<>(subject, controller);
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public static <Key, Data> StateFlowPagedData<Key, Data> createForStateFlow(@NonNull PagedDataSource<Key, Data> dataSource, @NonNull PagingConfig config) {
|
||||
MutableStateFlow<List<Data>> stateFlow = kotlinx.coroutines.flow.StateFlowKt.MutableStateFlow(java.util.Collections.emptyList());
|
||||
PagingController<Key> controller = new BufferedPagingController<>(dataSource, config, stateFlow::setValue);
|
||||
|
||||
return new StateFlowPagedData<>(stateFlow, controller);
|
||||
}
|
||||
|
||||
public PagingController<Key> getController() {
|
||||
return controller;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.signal.paging
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* An implementation of [PagedData] that will provide data as a [StateFlow].
|
||||
*/
|
||||
class StateFlowPagedData<Key, Data>(
|
||||
val data: StateFlow<List<Data>>,
|
||||
controller: PagingController<Key>
|
||||
) : PagedData<Key>(controller)
|
||||
Reference in New Issue
Block a user