From dd09496afb4df850b66b727742d2f9fd6eeacc59 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:48:12 +0300 Subject: [PATCH 01/16] stories: Base implementation of stories 1. Viewer video/photo 2. WIP Stories creation 3. Prefetch stories onload 4. Download 5. Miniapp stories impl 6. Impl in chatlist --- .../main/java/org/monogram/app/MainContent.kt | 2 + .../java/org/monogram/data/di/dataModule.kt | 11 + .../org/monogram/data/mapper/StoryMapper.kt | 182 ++ .../repository/ChatsListRepositoryImpl.kt | 16 + .../repository/LinkHandlerRepositoryImpl.kt | 32 +- .../data/repository/StoryRepositoryImpl.kt | 552 +++++ .../repository/StoryRepositoryStateReducer.kt | 149 ++ .../domain/models/stories/StoryModels.kt | 127 + .../domain/repository/ChatListRepository.kt | 1 + .../repository/LinkHandlerRepository.kt | 6 +- .../domain/repository/StoryRepository.kt | 36 + .../monogram/presentation/di/AppContainer.kt | 2 + .../presentation/di/KoinAppContainer.kt | 2 + .../chats/conversation/ChatComponent.kt | 1 + .../conversation/DefaultChatComponent.kt | 6 +- .../ui/content/ChatContentViewers.kt | 1 + .../features/chats/list/ChatListComponent.kt | 11 + .../features/chats/list/ChatListContent.kt | 122 +- .../chats/list/DefaultChatListComponent.kt | 123 + .../profile/DefaultProfileComponent.kt | 38 +- .../features/profile/ProfileComponent.kt | 8 +- .../features/profile/ProfileContent.kt | 90 + .../features/profile/ProfileViewers.kt | 1 + .../stories/DefaultStoriesHostComponent.kt | 595 +++++ .../features/stories/StoriesHostComponent.kt | 102 + .../features/stories/StoriesHostContent.kt | 2085 +++++++++++++++++ .../features/webapp/MiniAppState.kt | 7 +- .../features/webapp/MiniAppViewer.kt | 44 +- .../presentation/root/DefaultRootComponent.kt | 100 +- .../presentation/root/RootComponent.kt | 2 + presentation/src/main/res/values/string.xml | 43 + .../stories/StoriesHostContentTest.kt | 125 + 32 files changed, 4595 insertions(+), 27 deletions(-) create mode 100644 data/src/main/java/org/monogram/data/mapper/StoryMapper.kt create mode 100644 data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/repository/StoryRepositoryStateReducer.kt create mode 100644 domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt create mode 100644 presentation/src/test/java/org/monogram/presentation/features/stories/StoriesHostContentTest.kt diff --git a/app/src/main/java/org/monogram/app/MainContent.kt b/app/src/main/java/org/monogram/app/MainContent.kt index 8a926d9e6..674aad4f7 100644 --- a/app/src/main/java/org/monogram/app/MainContent.kt +++ b/app/src/main/java/org/monogram/app/MainContent.kt @@ -41,6 +41,7 @@ import org.monogram.presentation.features.chats.conversation.ui.StickerSetSheet import org.monogram.presentation.features.chats.conversation.ui.content.ChatContentViewers import org.monogram.presentation.features.profile.ProfileViewers import org.monogram.presentation.features.stickers.core.toDomain +import org.monogram.presentation.features.stories.StoriesHostContent import org.monogram.presentation.root.RootComponent import org.monogram.presentation.root.StartupComponent import org.monogram.presentation.root.StartupContent @@ -135,6 +136,7 @@ fun MainContent( ) { ProxyConfirmSheet(root) ChatConfirmJoinSheet(root) + StoriesHostContent(root.storiesHost) } if (!isStartupActive && startupOverlayComponent != null) { diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index 59418283a..0f189bcdf 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -116,6 +116,7 @@ import org.monogram.data.repository.SessionRepositoryImpl import org.monogram.data.repository.SponsorRepositoryImpl import org.monogram.data.repository.StickerRepositoryImpl import org.monogram.data.repository.StorageRepositoryImpl +import org.monogram.data.repository.StoryRepositoryImpl import org.monogram.data.repository.StreamingRepositoryImpl import org.monogram.data.repository.UpdateRepositoryImpl import org.monogram.data.repository.UserProfileEditRepositoryImpl @@ -160,6 +161,7 @@ import org.monogram.domain.repository.SessionRepository import org.monogram.domain.repository.SponsorRepository import org.monogram.domain.repository.StickerRepository import org.monogram.domain.repository.StorageRepository +import org.monogram.domain.repository.StoryRepository import org.monogram.domain.repository.StreamingRepository import org.monogram.domain.repository.StringProvider import org.monogram.domain.repository.UpdateRepository @@ -832,6 +834,15 @@ val dataModule = module { LinkHandlerRepositoryImpl(get(), get(), get(), get(), get()) } + single { + StoryRepositoryImpl( + gateway = get(), + updates = get(), + scope = get(), + fileDataSource = get() + ) + } + single { StreamingRepositoryImpl( fileDataSource = get(), diff --git a/data/src/main/java/org/monogram/data/mapper/StoryMapper.kt b/data/src/main/java/org/monogram/data/mapper/StoryMapper.kt new file mode 100644 index 000000000..81e2ceff0 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/StoryMapper.kt @@ -0,0 +1,182 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.domain.models.stories.ActiveStoryListModel +import org.monogram.domain.models.stories.StoryListType +import org.monogram.domain.models.stories.StoryMediaModel +import org.monogram.domain.models.stories.StoryMediaType +import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryPostCapabilityModel +import org.monogram.domain.models.stories.StoryPrivacyMode +import org.monogram.domain.models.stories.StoryPrivacySettingsModel +import org.monogram.domain.models.stories.StoryStealthModeModel +import org.monogram.domain.models.stories.StorySummaryModel + +object StoryMapper { + fun mapActiveStories(activeStories: TdApi.ChatActiveStories): ActiveStoryListModel { + val maxReadStoryId = activeStories.maxReadStoryId + return ActiveStoryListModel( + chatId = activeStories.chatId, + listType = activeStories.list.toDomainStoryListType() ?: StoryListType.MAIN, + order = activeStories.order, + canBeArchived = activeStories.canBeArchived, + maxReadStoryId = maxReadStoryId, + stories = activeStories.stories.orEmpty() + .map { summary -> + StorySummaryModel( + storyId = summary.storyId, + date = summary.date, + isForCloseFriends = summary.isForCloseFriends, + isLive = summary.isLive, + isRead = summary.storyId <= maxReadStoryId + ) + } + ) + } + + fun mapStory( + story: TdApi.Story, + activeStories: ActiveStoryListModel? = null, + mediaOverride: StoryMediaModel? = null + ): StoryModel { + return StoryModel( + id = story.id, + posterChatId = story.posterChatId, + date = story.date, + caption = story.caption?.text.orEmpty(), + media = mediaOverride ?: story.content.toDomainMedia(), + privacy = story.privacySettings.toDomainPrivacy(), + albumIds = story.albumIds?.toList().orEmpty(), + linkUrls = story.areas.orEmpty() + .mapNotNull { area -> (area.type as? TdApi.StoryAreaTypeLink)?.url }, + isBeingPosted = story.isBeingPosted, + isBeingEdited = story.isBeingEdited, + isEdited = story.isEdited, + isPostedToChatPage = story.isPostedToChatPage, + isVisibleOnlyForSelf = story.isVisibleOnlyForSelf, + canBeDeleted = story.canBeDeleted, + canBeEdited = story.canBeEdited, + canBeForwarded = story.canBeForwarded, + canBeReplied = story.canBeReplied, + canSetPrivacySettings = story.canSetPrivacySettings, + canToggleIsPostedToChatPage = story.canToggleIsPostedToChatPage, + canGetStatistics = story.canGetStatistics, + canGetInteractions = story.canGetInteractions, + hasExpiredViewers = story.hasExpiredViewers, + isRead = activeStories?.stories?.firstOrNull { it.storyId == story.id }?.isRead + ?: (story.id <= (activeStories?.maxReadStoryId ?: 0)) + ) + } + + fun mapStealthMode(update: TdApi.UpdateStoryStealthMode): StoryStealthModeModel { + return StoryStealthModeModel( + activeUntilDate = update.activeUntilDate, + cooldownUntilDate = update.cooldownUntilDate + ) + } + + fun mapPostCapability(result: TdApi.CanPostStoryResult?): StoryPostCapabilityModel { + return when (result) { + is TdApi.CanPostStoryResultOk -> StoryPostCapabilityModel.Allowed(result.storyCount) + is TdApi.CanPostStoryResultPremiumNeeded -> StoryPostCapabilityModel.PremiumNeeded + is TdApi.CanPostStoryResultBoostNeeded -> StoryPostCapabilityModel.BoostNeeded + is TdApi.CanPostStoryResultActiveStoryLimitExceeded -> StoryPostCapabilityModel.ActiveStoryLimitExceeded + is TdApi.CanPostStoryResultWeeklyLimitExceeded -> StoryPostCapabilityModel.WeeklyLimitExceeded( + result.retryAfter + ) + + is TdApi.CanPostStoryResultMonthlyLimitExceeded -> StoryPostCapabilityModel.MonthlyLimitExceeded( + result.retryAfter + ) + + is TdApi.CanPostStoryResultLiveStoryIsActive -> StoryPostCapabilityModel.LiveStoryActive( + result.storyId + ) + + else -> StoryPostCapabilityModel.Unknown("Unknown post capability") + } + } + + fun TdApi.StoryList?.toDomainStoryListType(): StoryListType? { + return when (this) { + is TdApi.StoryListMain -> StoryListType.MAIN + is TdApi.StoryListArchive -> StoryListType.ARCHIVE + else -> null + } + } + + fun StoryListType.toTdStoryList(): TdApi.StoryList { + return when (this) { + StoryListType.MAIN -> TdApi.StoryListMain() + StoryListType.ARCHIVE -> TdApi.StoryListArchive() + } + } + + fun StoryPrivacySettingsModel.toTdPrivacy(): TdApi.StoryPrivacySettings { + return when (mode) { + StoryPrivacyMode.EVERYONE -> TdApi.StoryPrivacySettingsEveryone(exceptUserIds.toLongArray()) + StoryPrivacyMode.CONTACTS -> TdApi.StoryPrivacySettingsContacts(exceptUserIds.toLongArray()) + StoryPrivacyMode.CLOSE_FRIENDS -> TdApi.StoryPrivacySettingsCloseFriends() + StoryPrivacyMode.SELECTED_USERS -> TdApi.StoryPrivacySettingsSelectedUsers( + selectedUserIds.toLongArray() + ) + } + } + + private fun TdApi.StoryPrivacySettings.toDomainPrivacy(): StoryPrivacySettingsModel { + return when (this) { + is TdApi.StoryPrivacySettingsEveryone -> StoryPrivacySettingsModel( + mode = StoryPrivacyMode.EVERYONE, + exceptUserIds = exceptUserIds?.toList().orEmpty() + ) + + is TdApi.StoryPrivacySettingsContacts -> StoryPrivacySettingsModel( + mode = StoryPrivacyMode.CONTACTS, + exceptUserIds = exceptUserIds?.toList().orEmpty() + ) + + is TdApi.StoryPrivacySettingsCloseFriends -> StoryPrivacySettingsModel( + mode = StoryPrivacyMode.CLOSE_FRIENDS + ) + + is TdApi.StoryPrivacySettingsSelectedUsers -> StoryPrivacySettingsModel( + mode = StoryPrivacyMode.SELECTED_USERS, + selectedUserIds = userIds?.toList().orEmpty() + ) + + else -> StoryPrivacySettingsModel(mode = StoryPrivacyMode.EVERYONE) + } + } + + private fun TdApi.StoryContent.toDomainMedia(): StoryMediaModel { + return when (this) { + is TdApi.StoryContentPhoto -> { + val size = photo.sizes?.maxByOrNull { it.width * it.height } + StoryMediaModel( + type = StoryMediaType.PHOTO, + path = size?.photo?.local?.path?.ifBlank { null }, + previewPath = photo.sizes?.firstOrNull()?.photo?.local?.path?.ifBlank { null }, + minithumbnail = photo.minithumbnail?.data?.takeIf { it.isNotEmpty() } + ) + } + + is TdApi.StoryContentVideo -> { + StoryMediaModel( + type = StoryMediaType.VIDEO, + path = video.video.local.path.ifBlank { null }, + previewPath = video.thumbnail?.file?.local?.path?.ifBlank { null }, + minithumbnail = video.minithumbnail?.data?.takeIf { it.isNotEmpty() }, + durationSeconds = video.duration, + isAnimation = video.isAnimation + ) + } + + else -> StoryMediaModel( + type = StoryMediaType.PHOTO, + path = null, + previewPath = null, + minithumbnail = null + ) + } + } +} diff --git a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt index c7608394e..5e55b7833 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -688,6 +688,22 @@ class ChatsListRepositoryImpl( }.getOrNull() } + override suspend fun isChatArchived(chatId: Long): Boolean? { + val chatObj = cache.getChat(chatId) + ?: chatLocalDataSource.getChat(chatId)?.let { entity -> + cache.putChatFromEntity(entity) + persistenceManager.rememberSavedEntity(entity) + cache.getChat(chatId) + } + ?: remoteDataSource.getChat(chatId)?.also { chat -> + cache.putChat(chat) + persistenceManager.scheduleChatSave(chat.id) + } + ?: return null + + return chatObj.positions.any { it.list is TdApi.ChatListArchive } + } + override suspend fun searchChats(query: String): List { return searchManager.searchChats(query) } diff --git a/data/src/main/java/org/monogram/data/repository/LinkHandlerRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/LinkHandlerRepositoryImpl.kt index 507972f38..c412cd365 100644 --- a/data/src/main/java/org/monogram/data/repository/LinkHandlerRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/LinkHandlerRepositoryImpl.kt @@ -7,6 +7,7 @@ import org.monogram.data.datasource.remote.LinkRemoteDataSource import org.monogram.data.infra.FileDownloadQueue import org.monogram.data.mapper.toLinkProxyTypeOrNull import org.monogram.data.mapper.toLinkSettingsType +import org.monogram.domain.models.stories.StoryMediaType import org.monogram.domain.repository.ChatInfoRepository import org.monogram.domain.repository.ChatListRepository import org.monogram.domain.repository.LinkAction @@ -61,8 +62,23 @@ class LinkHandlerRepositoryImpl( is TdApi.InternalLinkTypeBotStart -> handlePublicChat(internalLink.botUsername) is TdApi.InternalLinkTypeBotStartInGroup -> handlePublicChat(internalLink.botUsername) is TdApi.InternalLinkTypeVideoChat -> handlePublicChat(internalLink.chatUsername) - is TdApi.InternalLinkTypeStory -> handlePublicChat(internalLink.storyPosterUsername) - is TdApi.InternalLinkTypeStoryAlbum -> handlePublicChat(internalLink.storyAlbumOwnerUsername) + is TdApi.InternalLinkTypeStory -> handleStoryLink( + internalLink.storyPosterUsername, + internalLink.storyId + ) + + is TdApi.InternalLinkTypeStoryAlbum -> handleStoryAlbumLink( + internalLink.storyAlbumOwnerUsername, + internalLink.storyAlbumId + ) + + is TdApi.InternalLinkTypeNewStory -> LinkAction.OpenNewStory( + when (internalLink.contentType) { + is TdApi.StoryContentTypePhoto -> StoryMediaType.PHOTO + is TdApi.StoryContentTypeVideo -> StoryMediaType.VIDEO + else -> null + } + ) is TdApi.InternalLinkTypeMyProfilePage -> handleMyProfileLink() is TdApi.InternalLinkTypeSavedMessages -> handleSavedMessagesLink() is TdApi.InternalLinkTypeUserPhoneNumber -> @@ -132,6 +148,18 @@ class LinkHandlerRepositoryImpl( return resolveChatAction(chat) } + private suspend fun handleStoryLink(username: String, storyId: Int): LinkAction { + val chat = remote.searchPublicChat(username) + ?: return LinkAction.ShowToast(MSG_CHAT_NOT_FOUND) + return LinkAction.OpenStory(chat.id, storyId) + } + + private suspend fun handleStoryAlbumLink(username: String, albumId: Int): LinkAction { + val chat = remote.searchPublicChat(username) + ?: return LinkAction.ShowToast(MSG_CHAT_NOT_FOUND) + return LinkAction.OpenStoryAlbum(chat.id, albumId) + } + private suspend fun handleMyProfileLink(): LinkAction { val me = remote.getMe() ?: return LinkAction.ShowToast(MSG_USER_NOT_FOUND) return LinkAction.OpenUser(me.id) diff --git a/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt new file mode 100644 index 000000000..bc2a45d77 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt @@ -0,0 +1,552 @@ +package org.monogram.data.repository + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.drinkless.tdlib.TdApi +import org.json.JSONObject +import org.monogram.data.datasource.FileDataSource +import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.gateway.UpdateDispatcher +import org.monogram.data.mapper.StoryMapper +import org.monogram.data.mapper.StoryMapper.toDomainStoryListType +import org.monogram.data.mapper.StoryMapper.toTdPrivacy +import org.monogram.data.mapper.StoryMapper.toTdStoryList +import org.monogram.domain.models.stories.ActiveStoryListModel +import org.monogram.domain.models.stories.StoryComposerDraftModel +import org.monogram.domain.models.stories.StoryListType +import org.monogram.domain.models.stories.StoryMediaModel +import org.monogram.domain.models.stories.StoryMediaType +import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryPostCapabilityModel +import org.monogram.domain.models.stories.StoryPostResultModel +import org.monogram.domain.models.stories.StoryStealthModeModel +import org.monogram.domain.repository.StoryRepository + +class StoryRepositoryImpl( + private val gateway: TelegramGateway, + private val updates: UpdateDispatcher, + private val scope: CoroutineScope, + private val fileDataSource: FileDataSource +) : StoryRepository { + + private val state = MutableStateFlow(StoryRepositoryState()) + + private val _activeStories = + MutableStateFlow>>(emptyMap()) + override val activeStories: StateFlow>> = + _activeStories.asStateFlow() + + private val _storyListChatCounts = MutableStateFlow>(emptyMap()) + override val storyListChatCounts: StateFlow> = + _storyListChatCounts.asStateFlow() + + private val _stealthMode = MutableStateFlow(StoryStealthModeModel()) + override val stealthMode: StateFlow = _stealthMode.asStateFlow() + + private val _lastPostResult = MutableStateFlow(null) + override val lastPostResult: StateFlow = _lastPostResult.asStateFlow() + + init { + scope.launch { + updates.all.collect(::handleUpdate) + } + } + + override suspend fun loadActiveStories(listType: StoryListType) { + Log.d(TAG, "loadActiveStories request listType=$listType") + runCatching { + gateway.execute(TdApi.LoadActiveStories(listType.toTdStoryList())) + }.onSuccess { + Log.d(TAG, "loadActiveStories dispatched listType=$listType") + }.onFailure { + Log.d(TAG, "loadActiveStories failed for $listType: ${it.message}") + } + } + + override suspend fun getChatActiveStories(chatId: Long): ActiveStoryListModel? { + val existing = state.value.activeStories.values + .asSequence() + .flatMap(List::asSequence) + .firstOrNull { it.chatId == chatId } + if (existing != null) { + Log.d( + TAG, + "getChatActiveStories cache hit chatId=$chatId list=${existing.listType} stories=${existing.stories.size}" + ) + return existing + } + + return runCatching { + Log.d(TAG, "getChatActiveStories remote fetch chatId=$chatId") + StoryMapper.mapActiveStories( + gateway.execute(TdApi.GetChatActiveStories(chatId)) + ) + }.onSuccess { + Log.d( + TAG, + "getChatActiveStories fetched chatId=$chatId list=${it.listType} stories=${it.stories.size}" + ) + applyState(StoryRepositoryStateReducer.withActiveStories(state.value, it)) + } + .getOrNull() + } + + override suspend fun getStory(chatId: Long, storyId: Int, onlyLocal: Boolean): StoryModel? { + state.value.storyCache[StoryKey(chatId, storyId)]?.let { cached -> + if (cached.hasRenderableMedia() || onlyLocal) { + return cached + } + } + return runCatching { + val tdStory = gateway.execute(TdApi.GetStory(chatId, storyId, onlyLocal)) + val active = getChatActiveStories(chatId) + val resolvedMedia = resolveStoryMedia(tdStory.content) + StoryMapper.mapStory(tdStory, active, resolvedMedia) + }.onSuccess { + Log.d( + TAG, + "getStory mapped chatId=$chatId storyId=$storyId mediaType=${it.media.type} path=${it.media.path} preview=${it.media.previewPath}" + ) + applyState(StoryRepositoryStateReducer.withStory(state.value, it)) + } + .getOrNull() + } + + override suspend fun getStoryAlbum( + chatId: Long, + albumId: Int, + offset: Int, + limit: Int + ): List { + return runCatching { + val stories = + gateway.execute(TdApi.GetStoryAlbumStories(chatId, albumId, offset, limit)) + val active = getChatActiveStories(chatId) + stories.stories.orEmpty().map { story -> + StoryMapper.mapStory(story, active, resolveStoryMedia(story.content)) + } + }.onSuccess { mapped -> + var current = state.value + mapped.forEach { story -> + current = StoryRepositoryStateReducer.withStory(current, story) + } + applyState(current) + }.getOrElse { emptyList() } + } + + override suspend fun openStory(chatId: Long, storyId: Int) { + runCatching { gateway.execute(TdApi.OpenStory(chatId, storyId)) } + } + + override suspend fun closeStory(chatId: Long, storyId: Int) { + runCatching { gateway.execute(TdApi.CloseStory(chatId, storyId)) } + } + + override suspend fun canPostStory(chatId: Long): StoryPostCapabilityModel { + return runCatching { + StoryMapper.mapPostCapability(gateway.execute(TdApi.CanPostStory(chatId))) + }.getOrElse { StoryPostCapabilityModel.Unknown(it.message ?: "Unable to check capability") } + } + + override suspend fun postStory( + chatId: Long, + draft: StoryComposerDraftModel + ): StoryPostResultModel { + return runCatching { + val story = gateway.execute( + TdApi.PostStory( + chatId, + draft.toTdContent(), + draft.widgetLink.toTdAreas(), + TdApi.FormattedText(draft.caption, emptyArray()), + draft.privacy.toTdPrivacy(), + intArrayOf(), + draft.activePeriodSeconds, + null, + draft.keepOnProfile, + draft.protectContent + ) + ) + val mapped = StoryMapper.mapStory(story, getChatActiveStories(chatId)) + val result = StoryPostResultModel.Success(mapped) + applyState( + StoryRepositoryStateReducer.withStory(state.value, mapped) + .copy(lastPostResult = result) + ) + result + }.getOrElse { error -> + val result = StoryPostResultModel.Failure( + story = null, + message = error.message ?: "Failed to post story" + ) + applyState(state.value.copy(lastPostResult = result)) + result + } + } + + override suspend fun editStory( + chatId: Long, + storyId: Int, + draft: StoryComposerDraftModel + ): Boolean { + return runCatching { + gateway.execute( + TdApi.EditStory( + chatId, + storyId, + draft.toTdContent(), + draft.widgetLink.toTdAreas(), + TdApi.FormattedText(draft.caption, emptyArray()) + ) + ) + true + }.getOrDefault(false) + } + + override suspend fun deleteStory(chatId: Long, storyId: Int): Boolean { + return runCatching { + gateway.execute(TdApi.DeleteStory(chatId, storyId)) + applyState(StoryRepositoryStateReducer.withStoryDeleted(state.value, chatId, storyId)) + true + }.getOrDefault(false) + } + + override suspend fun setChatActiveStoriesList(chatId: Long, listType: StoryListType?): Boolean { + return runCatching { + gateway.execute(TdApi.SetChatActiveStoriesList(chatId, listType?.toTdStoryList())) + true + }.getOrDefault(false) + } + + override fun clearLastPostResult() { + applyState(StoryRepositoryStateReducer.clearLastPostResult(state.value)) + } + + private fun handleUpdate(update: TdApi.Update) { + when (update) { + is TdApi.UpdateChatActiveStories -> { + applyState( + StoryRepositoryStateReducer.withActiveStories( + state.value, + StoryMapper.mapActiveStories(update.activeStories) + ) + ) + } + + is TdApi.UpdateStory -> { + val active = state.value.activeStories.values + .asSequence() + .flatMap(List::asSequence) + .firstOrNull { it.chatId == update.story.posterChatId } + applyState( + StoryRepositoryStateReducer.withStory( + state.value, + StoryMapper.mapStory( + update.story, + active, + mapStoryMediaBestEffort(update.story.content) + ) + ) + ) + } + + is TdApi.UpdateStoryDeleted -> { + applyState( + StoryRepositoryStateReducer.withStoryDeleted( + state.value, + update.storyPosterChatId, + update.storyId + ) + ) + } + + is TdApi.UpdateStoryPostSucceeded -> { + val active = state.value.activeStories.values + .asSequence() + .flatMap(List::asSequence) + .firstOrNull { it.chatId == update.story.posterChatId } + applyState( + StoryRepositoryStateReducer.withPostSucceeded( + state.value, + StoryMapper.mapStory( + update.story, + active, + mapStoryMediaBestEffort(update.story.content) + ), + update.oldStoryId + ) + ) + } + + is TdApi.UpdateStoryPostFailed -> { + val active = state.value.activeStories.values + .asSequence() + .flatMap(List::asSequence) + .firstOrNull { it.chatId == update.story.posterChatId } + applyState( + StoryRepositoryStateReducer.withPostFailed( + state.value, + StoryMapper.mapStory( + update.story, + active, + mapStoryMediaBestEffort(update.story.content) + ), + update.error.message, + StoryMapper.mapPostCapability(update.errorType) + ) + ) + } + + is TdApi.UpdateStoryListChatCount -> { + update.storyList.toDomainStoryListType()?.let { listType -> + applyState( + StoryRepositoryStateReducer.withStoryListChatCount( + state.value, + listType, + update.chatCount + ) + ) + } + } + + is TdApi.UpdateStoryStealthMode -> { + applyState( + StoryRepositoryStateReducer.withStealthMode( + state.value, + StoryMapper.mapStealthMode(update) + ) + ) + } + } + } + + private fun applyState(newState: StoryRepositoryState) { + state.value = newState + _activeStories.value = newState.activeStories + _storyListChatCounts.value = newState.storyListChatCounts + _stealthMode.value = newState.stealthMode + _lastPostResult.value = newState.lastPostResult + Log.d( + TAG, + "applyState main=${newState.activeStories[StoryListType.MAIN].orEmpty().size} archive=${newState.activeStories[StoryListType.ARCHIVE].orEmpty().size} cache=${newState.storyCache.size}" + ) + } + + private fun StoryComposerDraftModel.toTdContent(): TdApi.InputStoryContent { + val inputFile = if (sourcePath.startsWith("http://") || sourcePath.startsWith("https://")) { + TdApi.InputFileRemote(sourcePath) + } else { + TdApi.InputFileLocal(sourcePath) + } + return when (mediaType) { + StoryMediaType.PHOTO -> TdApi.InputStoryContentPhoto( + inputFile, + intArrayOf() + ) + + StoryMediaType.VIDEO -> TdApi.InputStoryContentVideo( + inputFile, + intArrayOf(), + 0.0, + 0.0, + false + ) + } + } + + private fun String?.toTdAreas(): TdApi.InputStoryAreas? { + if (this.isNullOrBlank()) return null + val url = parseWidgetUrl(this) ?: return null + return TdApi.InputStoryAreas( + arrayOf( + TdApi.InputStoryArea( + TdApi.StoryAreaPosition(50.0, 82.0, 72.0, 14.0, 0.0, 8.0), + TdApi.InputStoryAreaTypeLink(url) + ) + ) + ) + } + + private fun parseWidgetUrl(widgetLink: String): String? { + return runCatching { + val json = JSONObject(widgetLink) + json.optString("url") + .ifBlank { json.optString("link") } + .ifBlank { null } + }.getOrElse { + widgetLink.takeIf { raw -> + raw.startsWith("http://") || raw.startsWith("https://") || raw.startsWith( + "tg://" + ) + } + } + } + + private suspend fun resolveStoryMedia(content: TdApi.StoryContent): StoryMediaModel { + return when (content) { + is TdApi.StoryContentPhoto -> { + val sortedSizes = content.photo.sizes.orEmpty() + .sortedByDescending { it.width * it.height } + val bestSize = sortedSizes.firstOrNull() + val previewSize = sortedSizes.lastOrNull() ?: bestSize + logPhotoCandidates(sortedSizes) + val previewPath = + resolveFilePath(previewSize?.photo, priority = 12, synchronous = true) + val bestPath = resolveFilePath(bestSize?.photo, priority = 28, synchronous = false) + StoryMediaModel( + type = StoryMediaType.PHOTO, + path = bestPath, + previewPath = previewPath ?: bestPath, + minithumbnail = content.photo.minithumbnail?.data?.takeIf { it.isNotEmpty() } + ) + } + + is TdApi.StoryContentVideo -> { + Log.d( + TAG, + "resolveStoryMedia video fileId=${content.video.video.id} thumbId=${content.video.thumbnail?.file?.id} localPath=${content.video.video.local.path}" + ) + val previewPath = resolveFilePath( + content.video.thumbnail?.file, + priority = 12, + synchronous = true + ) + val videoPath = + resolveFilePath(content.video.video, priority = 20, synchronous = false) + StoryMediaModel( + type = StoryMediaType.VIDEO, + path = videoPath, + previewPath = previewPath, + minithumbnail = content.video.minithumbnail?.data?.takeIf { it.isNotEmpty() }, + durationSeconds = content.video.duration, + isAnimation = content.video.isAnimation + ) + } + + else -> StoryMediaModel( + type = StoryMediaType.PHOTO, + path = null, + previewPath = null, + minithumbnail = null + ) + } + } + + private fun mapStoryMediaBestEffort(content: TdApi.StoryContent): StoryMediaModel { + return when (content) { + is TdApi.StoryContentPhoto -> { + val bestSize = content.photo.sizes?.maxByOrNull { it.width * it.height } + val previewSize = content.photo.sizes?.firstOrNull() + StoryMediaModel( + type = StoryMediaType.PHOTO, + path = bestSize?.photo?.local?.path?.ifBlank { null }, + previewPath = previewSize?.photo?.local?.path?.ifBlank { null }, + minithumbnail = content.photo.minithumbnail?.data?.takeIf { it.isNotEmpty() } + ) + } + + is TdApi.StoryContentVideo -> StoryMediaModel( + type = StoryMediaType.VIDEO, + path = content.video.video.local.path.ifBlank { null }, + previewPath = content.video.thumbnail?.file?.local?.path?.ifBlank { null }, + minithumbnail = content.video.minithumbnail?.data?.takeIf { it.isNotEmpty() }, + durationSeconds = content.video.duration, + isAnimation = content.video.isAnimation + ) + + else -> StoryMediaModel( + type = StoryMediaType.PHOTO, + path = null, + previewPath = null, + minithumbnail = null + ) + } + } + + private suspend fun resolveFilePath( + file: TdApi.File?, + priority: Int, + synchronous: Boolean + ): String? { + if (file == null) return null + file.local?.path?.takeIf { it.isNotBlank() }?.let { return it } + if (file.id == 0) return null + + val initial = runCatching { + fileDataSource.downloadFile( + fileId = file.id, + priority = priority, + offset = 0, + limit = 0, + synchronous = synchronous + ) + }.onFailure { + Log.d( + TAG, + "resolveFilePath failed fileId=${file.id} sync=$synchronous message=${it.message}" + ) + }.getOrNull() + + val initialPath = initial?.local?.path?.takeIf { it.isNotBlank() } + if (initialPath != null) { + Log.d( + TAG, + "resolveFilePath immediate fileId=${file.id} sync=$synchronous path=$initialPath" + ) + return initialPath + } + + repeat(if (synchronous) 3 else 6) { attempt -> + delay(if (synchronous) 120L else 250L * (attempt + 1)) + val refreshed = runCatching { fileDataSource.getFile(file.id) } + .onFailure { + Log.d( + TAG, + "resolveFilePath getFile failed fileId=${file.id} attempt=${attempt + 1} message=${it.message}" + ) + } + .getOrNull() + val refreshedPath = refreshed?.local?.path?.takeIf { it.isNotBlank() } + Log.d( + TAG, + "resolveFilePath poll fileId=${file.id} attempt=${attempt + 1} sync=$synchronous path=$refreshedPath completed=${refreshed?.local?.isDownloadingCompleted} active=${refreshed?.local?.isDownloadingActive} downloaded=${refreshed?.local?.downloadedSize}" + ) + if (refreshedPath != null) { + return refreshedPath + } + } + + Log.d( + TAG, + "resolveFilePath unresolved fileId=${file.id} sync=$synchronous completed=${initial?.local?.isDownloadingCompleted} active=${initial?.local?.isDownloadingActive} canDownload=${initial?.local?.canBeDownloaded} downloaded=${initial?.local?.downloadedSize} path=${initial?.local?.path}" + ) + return null + } + + private fun logPhotoCandidates(sizes: List) { + if (sizes.isEmpty()) { + Log.d(TAG, "resolveStoryMedia photo has no sizes") + return + } + val summary = sizes.joinToString { size -> + val local = size.photo.local + "${size.type}:${size.width}x${size.height}:file=${size.photo.id}:path=${local.path}:done=${local.isDownloadingCompleted}:active=${local.isDownloadingActive}" + } + Log.d(TAG, "resolveStoryMedia photo sizes $summary") + } + + private fun StoryModel.hasRenderableMedia(): Boolean { + return !media.path.isNullOrBlank() || + !media.previewPath.isNullOrBlank() || + media.minithumbnail?.isNotEmpty() == true + } + + companion object { + private const val TAG = "StoryRepository" + } +} diff --git a/data/src/main/java/org/monogram/data/repository/StoryRepositoryStateReducer.kt b/data/src/main/java/org/monogram/data/repository/StoryRepositoryStateReducer.kt new file mode 100644 index 000000000..ee00752c2 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/StoryRepositoryStateReducer.kt @@ -0,0 +1,149 @@ +package org.monogram.data.repository + +import org.monogram.domain.models.stories.ActiveStoryListModel +import org.monogram.domain.models.stories.StoryListType +import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryPostResultModel +import org.monogram.domain.models.stories.StoryStealthModeModel + +internal data class StoryKey( + val chatId: Long, + val storyId: Int +) + +internal data class StoryRepositoryState( + val activeStories: Map> = emptyMap(), + val storyCache: Map = emptyMap(), + val storyListChatCounts: Map = emptyMap(), + val stealthMode: StoryStealthModeModel = StoryStealthModeModel(), + val lastPostResult: StoryPostResultModel? = null +) + +internal object StoryRepositoryStateReducer { + fun withActiveStories( + state: StoryRepositoryState, + active: ActiveStoryListModel + ): StoryRepositoryState { + val currentList = state.activeStories[active.listType].orEmpty() + .filterNot { it.chatId == active.chatId } + val updatedList = (currentList + active) + .sortedWith(compareByDescending { it.order }.thenByDescending { it.chatId }) + return state.copy( + activeStories = state.activeStories + (active.listType to updatedList), + storyCache = state.storyCache + active.stories.associate { summary -> + val existing = state.storyCache[StoryKey(active.chatId, summary.storyId)] + StoryKey(active.chatId, summary.storyId) to (existing?.copy(isRead = summary.isRead) + ?: StoryModel( + id = summary.storyId, + posterChatId = active.chatId, + date = summary.date, + caption = "", + media = existing?.media + ?: org.monogram.domain.models.stories.StoryMediaModel( + type = org.monogram.domain.models.stories.StoryMediaType.PHOTO, + path = null, + previewPath = null + ), + privacy = existing?.privacy + ?: org.monogram.domain.models.stories.StoryPrivacySettingsModel( + mode = org.monogram.domain.models.stories.StoryPrivacyMode.EVERYONE + ), + isRead = summary.isRead + )) + } + ) + } + + fun withStory( + state: StoryRepositoryState, + story: StoryModel + ): StoryRepositoryState { + val key = StoryKey(story.posterChatId, story.id) + val active = state.findActiveStories(story.posterChatId) + val normalized = if (active != null) { + story.copy( + isRead = active.stories.firstOrNull { it.storyId == story.id }?.isRead + ?: story.isRead + ) + } else { + story + } + return state.copy(storyCache = state.storyCache + (key to normalized)) + } + + fun withStoryDeleted( + state: StoryRepositoryState, + chatId: Long, + storyId: Int + ): StoryRepositoryState { + val newActiveStories = state.activeStories.mapValues { (_, list) -> + list.mapNotNull { active -> + if (active.chatId != chatId) { + active + } else { + val remaining = active.stories.filterNot { it.storyId == storyId } + if (remaining.isEmpty()) { + null + } else { + active.copy(stories = remaining) + } + } + } + }.filterValues { it.isNotEmpty() } + return state.copy( + activeStories = newActiveStories, + storyCache = state.storyCache - StoryKey(chatId, storyId) + ) + } + + fun withPostSucceeded( + state: StoryRepositoryState, + story: StoryModel, + oldStoryId: Int + ): StoryRepositoryState { + val withoutOld = state.storyCache - StoryKey(story.posterChatId, oldStoryId) + val withStory = withStory(state.copy(storyCache = withoutOld), story) + return withStory.copy(lastPostResult = StoryPostResultModel.Success(story, oldStoryId)) + } + + fun withPostFailed( + state: StoryRepositoryState, + story: StoryModel?, + message: String, + capability: org.monogram.domain.models.stories.StoryPostCapabilityModel? + ): StoryRepositoryState { + val withStory = if (story != null) withStory(state, story) else state + return withStory.copy( + lastPostResult = StoryPostResultModel.Failure( + story = story, + message = message, + capability = capability + ) + ) + } + + fun withStoryListChatCount( + state: StoryRepositoryState, + listType: StoryListType, + chatCount: Int + ): StoryRepositoryState { + return state.copy(storyListChatCounts = state.storyListChatCounts + (listType to chatCount)) + } + + fun withStealthMode( + state: StoryRepositoryState, + stealthMode: StoryStealthModeModel + ): StoryRepositoryState { + return state.copy(stealthMode = stealthMode) + } + + fun clearLastPostResult(state: StoryRepositoryState): StoryRepositoryState { + return state.copy(lastPostResult = null) + } + + private fun StoryRepositoryState.findActiveStories(chatId: Long): ActiveStoryListModel? { + return activeStories.values.asSequence() + .flatMap(List::asSequence) + .firstOrNull { it.chatId == chatId } + } +} diff --git a/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt b/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt new file mode 100644 index 000000000..cdc3adb66 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt @@ -0,0 +1,127 @@ +package org.monogram.domain.models.stories + +data class ActiveStoryListModel( + val chatId: Long, + val listType: StoryListType, + val order: Long, + val canBeArchived: Boolean, + val maxReadStoryId: Int, + val stories: List +) + +data class StorySummaryModel( + val storyId: Int, + val date: Int, + val isForCloseFriends: Boolean, + val isLive: Boolean, + val isRead: Boolean +) + +data class StoryModel( + val id: Int, + val posterChatId: Long, + val date: Int, + val caption: String, + val media: StoryMediaModel, + val privacy: StoryPrivacySettingsModel, + val albumIds: List = emptyList(), + val linkUrls: List = emptyList(), + val isBeingPosted: Boolean = false, + val isBeingEdited: Boolean = false, + val isEdited: Boolean = false, + val isPostedToChatPage: Boolean = false, + val isVisibleOnlyForSelf: Boolean = false, + val canBeDeleted: Boolean = false, + val canBeEdited: Boolean = false, + val canBeForwarded: Boolean = false, + val canBeReplied: Boolean = false, + val canSetPrivacySettings: Boolean = false, + val canToggleIsPostedToChatPage: Boolean = false, + val canGetStatistics: Boolean = false, + val canGetInteractions: Boolean = false, + val hasExpiredViewers: Boolean = false, + val isRead: Boolean = false +) + +data class StoryMediaModel( + val type: StoryMediaType, + val path: String?, + val previewPath: String?, + val minithumbnail: ByteArray? = null, + val durationSeconds: Double? = null, + val isAnimation: Boolean = false +) + +data class StoryViewerItemModel( + val posterChatId: Long, + val storyId: Int +) + +data class StoryComposerDraftModel( + val sourcePath: String = "", + val mediaType: StoryMediaType = StoryMediaType.PHOTO, + val caption: String = "", + val privacy: StoryPrivacySettingsModel = StoryPrivacySettingsModel(mode = StoryPrivacyMode.EVERYONE), + val activePeriodSeconds: Int = DEFAULT_ACTIVE_PERIOD_SECONDS, + val protectContent: Boolean = false, + val keepOnProfile: Boolean = true, + val widgetLink: String? = null +) { + val isValid: Boolean + get() = sourcePath.isNotBlank() + + companion object { + const val DEFAULT_ACTIVE_PERIOD_SECONDS = 24 * 60 * 60 + } +} + +data class StoryPrivacySettingsModel( + val mode: StoryPrivacyMode, + val exceptUserIds: List = emptyList(), + val selectedUserIds: List = emptyList() +) + +data class StoryStealthModeModel( + val activeUntilDate: Int = 0, + val cooldownUntilDate: Int = 0 +) { + val isActive: Boolean + get() = activeUntilDate > 0 +} + +sealed class StoryPostCapabilityModel { + data class Allowed(val remainingCount: Int) : StoryPostCapabilityModel() + data object PremiumNeeded : StoryPostCapabilityModel() + data object BoostNeeded : StoryPostCapabilityModel() + data object ActiveStoryLimitExceeded : StoryPostCapabilityModel() + data class WeeklyLimitExceeded(val retryAfterSeconds: Int) : StoryPostCapabilityModel() + data class MonthlyLimitExceeded(val retryAfterSeconds: Int) : StoryPostCapabilityModel() + data class LiveStoryActive(val storyId: Int) : StoryPostCapabilityModel() + data class Unknown(val message: String) : StoryPostCapabilityModel() +} + +sealed class StoryPostResultModel { + data class Success(val story: StoryModel, val oldStoryId: Int? = null) : StoryPostResultModel() + data class Failure( + val story: StoryModel?, + val message: String, + val capability: StoryPostCapabilityModel? = null + ) : StoryPostResultModel() +} + +enum class StoryListType { + MAIN, + ARCHIVE +} + +enum class StoryMediaType { + PHOTO, + VIDEO +} + +enum class StoryPrivacyMode { + EVERYONE, + CONTACTS, + CLOSE_FRIENDS, + SELECTED_USERS +} diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatListRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatListRepository.kt index f6d3ea5ec..f44b34611 100644 --- a/domain/src/main/java/org/monogram/domain/repository/ChatListRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/ChatListRepository.kt @@ -13,5 +13,6 @@ interface ChatListRepository { fun refresh() fun refreshOnResume() suspend fun getChatById(chatId: Long): ChatModel? + suspend fun isChatArchived(chatId: Long): Boolean? fun retryConnection() } \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/LinkHandlerRepository.kt b/domain/src/main/java/org/monogram/domain/repository/LinkHandlerRepository.kt index 6141f23af..6a4977c21 100644 --- a/domain/src/main/java/org/monogram/domain/repository/LinkHandlerRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/LinkHandlerRepository.kt @@ -3,6 +3,7 @@ package org.monogram.domain.repository import org.monogram.domain.models.ChatFullInfoModel import org.monogram.domain.models.ChatModel import org.monogram.domain.models.ProxyTypeModel +import org.monogram.domain.models.stories.StoryMediaType interface LinkHandlerRepository { suspend fun handleLink(link: String): LinkAction @@ -14,6 +15,9 @@ sealed class LinkAction { data class OpenChat(val chatId: Long) : LinkAction() data class OpenUser(val userId: Long) : LinkAction() data class OpenMessage(val chatId: Long, val messageId: Long) : LinkAction() + data class OpenStory(val chatId: Long, val storyId: Int) : LinkAction() + data class OpenStoryAlbum(val chatId: Long, val albumId: Int) : LinkAction() + data class OpenNewStory(val mediaType: StoryMediaType? = null) : LinkAction() data class OpenSettings(val settingsType: SettingsType) : LinkAction() data class OpenStickerSet(val name: String) : LinkAction() data class JoinChat(val inviteLink: String) : LinkAction() @@ -41,4 +45,4 @@ sealed class LinkAction { enum class SettingsType { MAIN, PRIVACY, SESSIONS, FOLDERS, CHAT, DATA_STORAGE, POWER_SAVING, PREMIUM } -} \ No newline at end of file +} diff --git a/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt b/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt new file mode 100644 index 000000000..e5ca665b4 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt @@ -0,0 +1,36 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.StateFlow +import org.monogram.domain.models.stories.ActiveStoryListModel +import org.monogram.domain.models.stories.StoryComposerDraftModel +import org.monogram.domain.models.stories.StoryListType +import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryPostCapabilityModel +import org.monogram.domain.models.stories.StoryPostResultModel +import org.monogram.domain.models.stories.StoryStealthModeModel + +interface StoryRepository { + val activeStories: StateFlow>> + val storyListChatCounts: StateFlow> + val stealthMode: StateFlow + val lastPostResult: StateFlow + + suspend fun loadActiveStories(listType: StoryListType) + suspend fun getChatActiveStories(chatId: Long): ActiveStoryListModel? + suspend fun getStory(chatId: Long, storyId: Int, onlyLocal: Boolean = false): StoryModel? + suspend fun getStoryAlbum( + chatId: Long, + albumId: Int, + offset: Int = 0, + limit: Int = 50 + ): List + + suspend fun openStory(chatId: Long, storyId: Int) + suspend fun closeStory(chatId: Long, storyId: Int) + suspend fun canPostStory(chatId: Long): StoryPostCapabilityModel + suspend fun postStory(chatId: Long, draft: StoryComposerDraftModel): StoryPostResultModel + suspend fun editStory(chatId: Long, storyId: Int, draft: StoryComposerDraftModel): Boolean + suspend fun deleteStory(chatId: Long, storyId: Int): Boolean + suspend fun setChatActiveStoriesList(chatId: Long, listType: StoryListType?): Boolean + fun clearLastPostResult() +} diff --git a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt index 5b45ea917..3d749e9f5 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt @@ -51,6 +51,7 @@ import org.monogram.domain.repository.SessionRepository import org.monogram.domain.repository.SponsorRepository import org.monogram.domain.repository.StickerRepository import org.monogram.domain.repository.StorageRepository +import org.monogram.domain.repository.StoryRepository import org.monogram.domain.repository.StringProvider import org.monogram.domain.repository.UpdateRepository import org.monogram.domain.repository.UserProfileEditRepository @@ -115,6 +116,7 @@ interface RepositoriesContainer { val proxyRepository: ProxyRepository val proxyDiagnosticsRepository: ProxyDiagnosticsRepository val stickerRepository: StickerRepository + val storyRepository: StoryRepository val gifRepository: GifRepository val emojiRepository: EmojiRepository val updateRepository: UpdateRepository diff --git a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt index e523210d8..1a08a2c7c 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt @@ -52,6 +52,7 @@ import org.monogram.domain.repository.SessionRepository import org.monogram.domain.repository.SponsorRepository import org.monogram.domain.repository.StickerRepository import org.monogram.domain.repository.StorageRepository +import org.monogram.domain.repository.StoryRepository import org.monogram.domain.repository.StringProvider import org.monogram.domain.repository.UpdateRepository import org.monogram.domain.repository.UserProfileEditRepository @@ -116,6 +117,7 @@ class KoinRepositoriesContainer(private val koin: Koin) : RepositoriesContainer override val proxyRepository: ProxyRepository by lazy { koin.get() } override val proxyDiagnosticsRepository: ProxyDiagnosticsRepository by lazy { koin.get() } override val stickerRepository: StickerRepository by lazy { koin.get() } + override val storyRepository: StoryRepository by lazy { koin.get() } override val gifRepository: GifRepository by lazy { koin.get() } override val emojiRepository: EmojiRepository by lazy { koin.get() } override val updateRepository: UpdateRepository by lazy { koin.get() } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt index dcc99b259..15ff0baba 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt @@ -173,6 +173,7 @@ interface ChatComponent { fun onOpenMiniApp(url: String, name: String, botUserId: Long = 0L) fun onDismissMiniApp() + fun onShareToStory(mediaUrl: String, text: String?, widgetLink: String?) fun onAcceptMiniAppTOS() fun onDismissMiniAppTOS() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt index 51db38b57..9a669808f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt @@ -279,7 +279,8 @@ class DefaultChatComponent( private val initialMessageId: Long? = null, private val initialTopicId: Long? = null, private val initialShare: IncomingShareRequest? = null, - private val onInitialShareConsumed: (Long) -> Unit = {} + private val onInitialShareConsumed: (Long) -> Unit = {}, + private val onShareToStoryRequested: (String, String?, String?) -> Unit = { _, _, _ -> } ) : ChatComponent, AppComponentContext by context { internal val componentInstanceId: String = ChatConversationLog.nextComponentInstanceId(chatId) @@ -981,6 +982,9 @@ class DefaultChatComponent( store.accept(ChatStore.Intent.OpenMiniApp(url, name, botUserId)) override fun onDismissMiniApp() = store.accept(ChatStore.Intent.DismissMiniApp) + override fun onShareToStory(mediaUrl: String, text: String?, widgetLink: String?) { + onShareToStoryRequested(mediaUrl, text, widgetLink) + } override fun onAcceptMiniAppTOS() = store.accept(ChatStore.Intent.AcceptMiniAppTOS) override fun onDismissMiniAppTOS() = store.accept(ChatStore.Intent.DismissMiniAppTOS) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentViewers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentViewers.kt index 8b20cd35c..42499f1c0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentViewers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentViewers.kt @@ -155,6 +155,7 @@ internal fun MiniAppOverlay(state: ChatComponent.State, component: ChatComponent botName = state.chatTitle, botAvatarPath = state.chatAvatar, webAppRepository = component.repositoryMessage, + onShareToStory = component::onShareToStory, onDismiss = { component.onDismissMiniApp() } ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListComponent.kt index 8935dd00a..b55368747 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListComponent.kt @@ -10,6 +10,7 @@ import org.monogram.domain.models.MessageModel import org.monogram.domain.models.TopicModel import org.monogram.domain.models.UpdateState import org.monogram.domain.models.UserModel +import org.monogram.domain.models.stories.ActiveStoryListModel import org.monogram.domain.repository.ConnectionStatus import org.monogram.domain.repository.ForwardTarget import org.monogram.presentation.core.util.AppPreferences @@ -23,6 +24,7 @@ interface ChatListComponent { val chatsState: StateFlow val selectionState: StateFlow val searchState: StateFlow + val storiesState: StateFlow val appPreferences: AppPreferences @@ -74,6 +76,9 @@ interface ChatListComponent { fun onOpenWebApp(url: String, botUserId: Long, botName: String) fun onDismissWebApp() + fun onShareToStory(mediaUrl: String, text: String?, widgetLink: String?) + fun onStoryClicked(chatId: Long, storyId: Int? = null) + fun onAddStoryClicked() fun onOpenWebView(url: String) fun onDismissWebView() @@ -215,6 +220,12 @@ interface ChatListComponent { val canLoadMoreMessages: Boolean = false ) + @Immutable + data class StoriesState( + val mainActiveStories: List = emptyList(), + val archiveActiveStories: List = emptyList() + ) + sealed class ShareResult { data class Selected(val target: ShareTarget) : ShareResult() data object Cancelled : ShareResult() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt index 416547ce7..bcf029ef0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt @@ -58,6 +58,7 @@ import androidx.compose.material.icons.automirrored.rounded.ExitToApp import androidx.compose.material.icons.automirrored.rounded.Send import androidx.compose.material.icons.automirrored.rounded.VolumeOff import androidx.compose.material.icons.automirrored.rounded.VolumeUp +import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Archive import androidx.compose.material.icons.rounded.CleaningServices import androidx.compose.material.icons.rounded.Close @@ -167,6 +168,9 @@ import org.monogram.presentation.features.instantview.InstantViewer import org.monogram.presentation.features.stickers.ui.menu.ActionMenuPopup import org.monogram.presentation.features.stickers.ui.menu.EmojisGrid import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow +import org.monogram.presentation.features.stories.StoriesStrip +import org.monogram.presentation.features.stories.StoryStripItemUiModel +import org.monogram.presentation.features.stories.shouldShowStoriesStrip import org.monogram.presentation.features.webapp.MiniAppViewer import kotlin.math.abs import kotlin.math.roundToInt @@ -184,6 +188,7 @@ fun ChatListContent( val chatsState by component.chatsState.collectAsState() val selectionState by component.selectionState.collectAsState() val searchState by component.searchState.collectAsState() + val storiesState by component.storiesState.collectAsState() val showAllChatsFolder by component.appPreferences.showAllChatsFolder.collectAsState() val isProjectChannelPromoDismissed by component.appPreferences.isProjectChannelPromoDismissed.collectAsState() @@ -854,14 +859,26 @@ fun ChatListContent( enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut() ) { - ExtendedFloatingActionButton( - onClick = { component.onNewChatClicked() }, - icon = { Icon(Icons.Rounded.Edit, null) }, - text = { Text(stringResource(R.string.new_chat_fab)) }, - expanded = isFabExpanded, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (effectiveFoldersState.selectedFolderId == -1) { + ExtendedFloatingActionButton( + onClick = component::onAddStoryClicked, + icon = { Icon(Icons.Rounded.Add, null) }, + text = { Text(stringResource(R.string.story_create)) }, + expanded = isFabExpanded, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + ExtendedFloatingActionButton( + onClick = { component.onNewChatClicked() }, + icon = { Icon(Icons.Rounded.Edit, null) }, + text = { Text(stringResource(R.string.new_chat_fab)) }, + expanded = isFabExpanded, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + } } } @@ -897,6 +914,8 @@ fun ChatListContent( isProjectChannelJoinInProgress = uiState.isProjectChannelJoinInProgress, onProjectChannelSubscribe = component::onProjectChannelSubscribe, onProjectChannelLater = component::onProjectChannelLater, + mainActiveStories = storiesState.mainActiveStories, + archiveActiveStories = storiesState.archiveActiveStories, onChatClicked = onChatClicked, onChatLongClicked = onChatLongClicked ) @@ -1087,6 +1106,7 @@ fun ChatListContent( baseUrl = webAppUrl ?: "", botName = botName ?: stringResource(R.string.mini_app_default_name), webAppRepository = koinInject(), + onShareToStory = component::onShareToStory, onDismiss = { component.onDismissWebApp() } ) } @@ -1353,6 +1373,8 @@ private fun ChatListBody( isProjectChannelJoinInProgress: Boolean, onProjectChannelSubscribe: () -> Unit, onProjectChannelLater: () -> Unit, + mainActiveStories: List, + archiveActiveStories: List, onChatClicked: (Long) -> Unit, onChatLongClicked: (Long) -> Unit ) { @@ -1375,6 +1397,7 @@ private fun ChatListBody( messageLines = messageLines, showPhotos = showPhotos, interactionsEnabled = interactionsEnabled, + archiveActiveStories = archiveActiveStories, onChatClicked = onChatClicked, onChatLongClicked = onChatLongClicked ) @@ -1400,6 +1423,7 @@ private fun ChatListBody( isProjectChannelJoinInProgress = isProjectChannelJoinInProgress, onProjectChannelSubscribe = onProjectChannelSubscribe, onProjectChannelLater = onProjectChannelLater, + activeStories = mainActiveStories, onChatClicked = onChatClicked, onChatLongClicked = onChatLongClicked ) @@ -1426,6 +1450,7 @@ private fun SearchOrArchiveContent( messageLines: Int, showPhotos: Boolean, interactionsEnabled: Boolean, + archiveActiveStories: List, onChatClicked: (Long) -> Unit, onChatLongClicked: (Long) -> Unit ) { @@ -1451,6 +1476,21 @@ private fun SearchOrArchiveContent( ) val archivedChats = if (isArchivedView) chatsState.chats else emptyList() + val archiveStoryStripItems = remember(archivedChats, archiveActiveStories) { + archiveActiveStories.mapNotNull { storyList -> + val chat = + archivedChats.firstOrNull { it.id == storyList.chatId } ?: return@mapNotNull null + StoryStripItemUiModel( + chatId = chat.id, + title = chat.title, + avatarPath = chat.avatarPath, + activeStories = storyList + ) + } + } + val hasArchiveStoriesStrip = isArchivedView && + shouldShowStoriesStrip(selectedFolderId = -2, isSearchActive = false) && + archiveStoryStripItems.isNotEmpty() val isArchivedLoading = isArchivedView && chatsState.isLoading val hasArchivedLoadState = isArchivedView && ( foldersState.isLoadingByFolder.containsKey(-2) || chatsState.chats.isNotEmpty() @@ -1493,14 +1533,16 @@ private fun SearchOrArchiveContent( isArchivedView, archivedChats.size, isArchivedLoading, + hasArchiveStoriesStrip, scrollState, interactionsEnabled, ) { if (!interactionsEnabled || !isArchivedView || isArchivedLoading || archivedChats.isEmpty()) return@LaunchedEffect + val headerItemsCount = if (hasArchiveStoriesStrip) 1 else 0 snapshotFlow { val lastVisible = scrollState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 - lastVisible >= archivedChats.lastIndex - 5 + (lastVisible - headerItemsCount) >= archivedChats.lastIndex - 5 } .distinctUntilChanged() .collect { shouldLoad -> @@ -1699,6 +1741,15 @@ private fun SearchOrArchiveContent( } } } else { + if (hasArchiveStoriesStrip) { + item { + StoriesStrip( + items = archiveStoryStripItems, + onStoryClick = component::onStoryClicked + ) + } + } + if (archivedChats.isEmpty() && hasArchivedLoadState && !isArchivedLoading) { item { EmptyStateView(modifier = Modifier.fillParentMaxSize()) @@ -1766,6 +1817,7 @@ private fun FolderPagerContent( isProjectChannelJoinInProgress: Boolean, onProjectChannelSubscribe: () -> Unit, onProjectChannelLater: () -> Unit, + activeStories: List, onChatClicked: (Long) -> Unit, onChatLongClicked: (Long) -> Unit ) { @@ -1806,6 +1858,7 @@ private fun FolderPagerContent( isProjectChannelJoinInProgress = isProjectChannelJoinInProgress, onProjectChannelSubscribe = onProjectChannelSubscribe, onProjectChannelLater = onProjectChannelLater, + activeStories = activeStories, onChatClicked = onChatClicked, onChatLongClicked = onChatLongClicked ) @@ -1838,9 +1891,23 @@ private fun FolderPageContent( isProjectChannelJoinInProgress: Boolean, onProjectChannelSubscribe: () -> Unit, onProjectChannelLater: () -> Unit, + activeStories: List, onChatClicked: (Long) -> Unit, onChatLongClicked: (Long) -> Unit ) { + val storyStripItems = remember(activeStories, folderChats) { + activeStories.mapNotNull { storyList -> + val chat = + folderChats.firstOrNull { it.id == storyList.chatId } ?: return@mapNotNull null + StoryStripItemUiModel( + chatId = chat.id, + title = chat.title, + avatarPath = chat.avatarPath, + activeStories = storyList + ) + } + } + val hasStoriesStrip = shouldShowStoriesStrip(folderId, false) && storyStripItems.isNotEmpty() val shouldHoldInitialFolderContent = folderId > 0 && shouldAnimateFirstFolderTransition && isFolderLoading @@ -1866,10 +1933,20 @@ private fun FolderPageContent( } } - LaunchedEffect(folderId, folderChats.size, isFolderLoading, scrollState, interactionsEnabled) { + LaunchedEffect( + folderId, + folderChats.size, + isFolderLoading, + hasStoriesStrip, + scrollState, + interactionsEnabled + ) { if (!interactionsEnabled || isFolderLoading || folderChats.isEmpty()) return@LaunchedEffect - val headerItemsCount = if (showProjectChannelPromo) 1 else 0 + val headerItemsCount = listOf( + showProjectChannelPromo, + hasStoriesStrip + ).count { it } snapshotFlow { val lastVisible = (scrollState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1) - headerItemsCount @@ -1883,10 +1960,20 @@ private fun FolderPageContent( } } - LaunchedEffect(folderId, folderChats, isFolderLoading, scrollState, interactionsEnabled) { + LaunchedEffect( + folderId, + folderChats, + isFolderLoading, + hasStoriesStrip, + scrollState, + interactionsEnabled + ) { if (!interactionsEnabled || isFolderLoading || folderChats.isEmpty()) return@LaunchedEffect - val headerItemsCount = if (showProjectChannelPromo) 1 else 0 + val headerItemsCount = listOf( + showProjectChannelPromo, + hasStoriesStrip + ).count { it } snapshotFlow { scrollState.layoutInfo.visibleItemsInfo .mapNotNull { item -> folderChats.getOrNull(item.index - headerItemsCount)?.id } @@ -1937,6 +2024,15 @@ private fun FolderPageContent( end = if (isTablet) 4.dp else 0.dp ) ) { + if (hasStoriesStrip) { + item { + StoriesStrip( + items = storyStripItems, + onStoryClick = component::onStoryClicked + ) + } + } + if (showProjectChannelPromo) { item { ProjectChannelPromoCard( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt index 85aa60854..e4e2ca301 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt @@ -37,6 +37,7 @@ import org.monogram.domain.repository.ForwardOptions import org.monogram.domain.repository.ForwardRequest import org.monogram.domain.repository.ForwardTarget import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.StoryRepository import org.monogram.domain.repository.UpdateRepository import org.monogram.domain.repository.UserProfileEditRepository import org.monogram.domain.repository.UserRepository @@ -62,6 +63,9 @@ class DefaultChatListComponent( private val onConfirmForward: (ForwardRequest) -> Unit = {}, private val onShareTargetSelected: (ShareTarget) -> Unit = {}, private val onShareCancelled: () -> Unit = {}, + private val onShareToStory: (String, String?, String?) -> Unit = { _, _, _ -> }, + private val onStorySelect: (Long, Int?) -> Unit = { _, _ -> }, + private val onAddStory: () -> Unit = {}, private val forwardingFromChatId: Long? = null, private val forwardingMessageIds: List = emptyList(), internal val isForwarding: Boolean = false, @@ -84,6 +88,7 @@ class DefaultChatListComponent( private val botRepository: BotRepository = container.repositories.botRepository private val attachMenuBotRepository: AttachMenuBotRepository = container.repositories.attachMenuBotRepository private val updateRepository: UpdateRepository = container.repositories.updateRepository + private val storyRepository: StoryRepository = container.repositories.storyRepository override val appPreferences: AppPreferences = container.preferences.appPreferences private val messageDisplayer = container.utils.messageDisplayer() @@ -100,6 +105,7 @@ class DefaultChatListComponent( private val _chatsState = MutableStateFlow(_state.value.toChatsState()) private val _selectionState = MutableStateFlow(_state.value.toSelectionState()) private val _searchState = MutableStateFlow(_state.value.toSearchState()) + private val _storiesState = MutableStateFlow(ChatListComponent.StoriesState()) private val store = instanceKeeper.getStore { ChatListStoreFactory( @@ -115,6 +121,8 @@ class DefaultChatListComponent( override val chatsState: StateFlow = _chatsState.asStateFlow() override val selectionState: StateFlow = _selectionState.asStateFlow() override val searchState: StateFlow = _searchState.asStateFlow() + override val storiesState: StateFlow = + _storiesState.asStateFlow() private val scope = componentScope private var searchJob: Job? = null @@ -123,6 +131,7 @@ class DefaultChatListComponent( private var hasSeenResume = false private val prefetchSemaphore = Semaphore(PREFETCH_CONCURRENCY) private val messagePrefetchTimestamps = mutableMapOf() + private val storyPrefetchChatIds = mutableSetOf() private fun ChatListComponent.State.toUiState() = ChatListComponent.UiState( currentUser = currentUser, @@ -311,6 +320,8 @@ class DefaultChatListComponent( it.copy(chatsByFolder = newChatsByFolder) } syncProjectChannelSubscriptionFromChats(normalizedList) + prefetchStoryListsForChats(update.folderId, normalizedList) + refreshStoriesState("folder_${update.folderId}") } .launchIn(scope) @@ -419,10 +430,22 @@ class DefaultChatListComponent( } .launchIn(scope) + storyRepository.activeStories + .onEach { activeStories -> + Log.d( + STORY_TAG, + "story repository update main=${activeStories[org.monogram.domain.models.stories.StoryListType.MAIN].orEmpty().size} " + + "archive=${activeStories[org.monogram.domain.models.stories.StoryListType.ARCHIVE].orEmpty().size}" + ) + refreshStoriesState("repository") + } + .launchIn(scope) + scope.launch { if (!isTelemtBuild) { updateRepository.checkForUpdates() } + refreshStoriesState("init") } _state.onEach { @@ -994,6 +1017,18 @@ class DefaultChatListComponent( } } + override fun onShareToStory(mediaUrl: String, text: String?, widgetLink: String?) { + onShareToStory.invoke(mediaUrl, text, widgetLink) + } + + override fun onStoryClicked(chatId: Long, storyId: Int?) { + onStorySelect(chatId, storyId) + } + + override fun onAddStoryClicked() { + onAddStory() + } + override fun onDismissWebApp() = store.accept(ChatListStore.Intent.DismissWebApp) internal fun handleDismissWebApp() { @@ -1205,6 +1240,92 @@ class DefaultChatListComponent( } } + private fun prefetchStoryListsForChats(folderId: Int, chats: List) { + val hintedChats = chats.asSequence() + .filter { it.activeStoryId != 0 || !it.activeStoryStateType.isNullOrBlank() } + .filter { chat -> + storyRepository.activeStories.value.values + .asSequence() + .flatMap(List::asSequence) + .none { active -> active.chatId == chat.id } + } + .filter { chat -> storyPrefetchChatIds.add(chat.id) } + .take(STORY_PREFETCH_MAX) + .toList() + + if (hintedChats.isEmpty()) return + + scope.launch(Dispatchers.IO) { + hintedChats.forEach { chat -> + Log.d( + STORY_TAG, + "prefetch story list folder=$folderId chatId=${chat.id} hintType=${chat.activeStoryStateType} hintId=${chat.activeStoryId}" + ) + coRunCatching { + storyRepository.getChatActiveStories(chat.id) + }.onFailure { error -> + Log.d( + STORY_TAG, + "prefetch story list failed chatId=${chat.id}: ${error.message}" + ) + } + } + refreshStoriesState("prefetch_$folderId") + } + } + + private fun refreshStoriesState(reason: String) { + scope.launch(Dispatchers.IO) { + val repositoryStories = storyRepository.activeStories.value + val activeStories = + repositoryStories[org.monogram.domain.models.stories.StoryListType.MAIN].orEmpty() + val archiveFolderChatIds = _state.value.chatsByFolder[ARCHIVE_FOLDER_ID] + .orEmpty() + .mapTo(mutableSetOf()) { it.id } + val localChatIndex = LinkedHashMap() + _state.value.chatsByFolder.values.forEach { chats -> + chats.forEach { chat -> localChatIndex.putIfAbsent(chat.id, chat) } + } + + val resolvedStories = activeStories.mapNotNull { storyList -> + val chat = localChatIndex[storyList.chatId] ?: chatListRepository.getChatById( + storyList.chatId + ) + if (chat == null) { + Log.d( + STORY_TAG, + "story chat metadata missing chatId=${storyList.chatId} reason=$reason" + ) + null + } else { + val isArchived = when { + storyList.chatId in archiveFolderChatIds -> true + else -> chatListRepository.isChatArchived(storyList.chatId) + ?: chat.isArchived + } + Triple(chat, storyList, isArchived) + } + } + + val mainStories = resolvedStories + .filterNot { (_, _, isArchived) -> isArchived } + .map { (_, stories, _) -> stories } + val archiveStories = resolvedStories + .filter { (_, _, isArchived) -> isArchived } + .map { (_, stories, _) -> stories } + + Log.d( + STORY_TAG, + "refreshStoriesState reason=$reason total=${activeStories.size} main=${mainStories.size} archived=${archiveStories.size} archiveFolderIds=${archiveFolderChatIds.size}" + ) + + _storiesState.value = ChatListComponent.StoriesState( + mainActiveStories = mainStories, + archiveActiveStories = archiveStories + ) + } + } + private fun syncProjectChannelSubscriptionFromChats(chats: List) { chats.firstOrNull { it.id == PROJECT_CHANNEL_CHAT_ID } ?.let(::syncProjectChannelSubscriptionFromChat) @@ -1241,12 +1362,14 @@ class DefaultChatListComponent( companion object { private const val TAG = "PinnedUiDiag" + private const val STORY_TAG = "StoriesDiag" private const val ARCHIVE_FOLDER_ID = -2 private const val PROJECT_CHANNEL_CHAT_ID = -1003768707135L private const val PREFETCH_MAX_CANDIDATES = 6 private const val PREFETCH_CONCURRENCY = 2 private const val PREFETCH_PAGE_SIZE = 30 private const val PREFETCH_TTL_MS = 60_000L + private const val STORY_PREFETCH_MAX = 12 } private fun toggleSelection(id: Long) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt index 5f8fa9056..21f530de6 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt @@ -37,6 +37,7 @@ import org.monogram.domain.repository.LocationRepository import org.monogram.domain.repository.MessageRepository import org.monogram.domain.repository.PrivacyRepository import org.monogram.domain.repository.ProfilePhotoRepository +import org.monogram.domain.repository.StoryRepository import org.monogram.domain.repository.UserRepository import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.coRunCatching @@ -57,7 +58,11 @@ class DefaultProfileComponent( private val onShowLogsClicked: (Long) -> Unit = {}, private val onEditContactClicked: (Long) -> Unit = {}, private val onMemberClicked: (Long) -> Unit = {}, - private val onMemberLongClicked: (Long, Long) -> Unit = { _, _ -> } + private val onMemberLongClicked: (Long, Long) -> Unit = { _, _ -> }, + private val onOpenStoriesClicked: (Long, Int?) -> Unit = { _, _ -> }, + private val onOpenStoryArchiveClicked: (Long) -> Unit = {}, + private val onCreateStoryClicked: (Long) -> Unit = {}, + private val onShareToStoryRequested: (String, String?, String?) -> Unit = { _, _, _ -> } ) : ProfileComponent, AppComponentContext by context { private val chatListRepository: ChatListRepository = container.repositories.chatListRepository @@ -72,6 +77,7 @@ class DefaultProfileComponent( override val messageRepository: MessageRepository = container.repositories.messageRepository private val locationRepository: LocationRepository = container.repositories.locationRepository private val gifRepository: GifRepository = container.repositories.gifRepository + private val storyRepository: StoryRepository = container.repositories.storyRepository private val botPreferences: BotPreferencesProvider = container.preferences.botPreferencesProvider private val stringProvider = container.utils.stringProvider() private val messageDisplayer = container.utils.messageDisplayer() @@ -183,6 +189,19 @@ class DefaultProfileComponent( _state.update { it.copy(currentUser = user) } } .launchIn(scope) + + storyRepository.activeStories + .onEach { activeStories -> + _state.update { + it.copy( + activeStoryList = activeStories.values + .asSequence() + .flatMap { lists -> lists.asSequence() } + .firstOrNull { list -> list.chatId == chatId } + ) + } + } + .launchIn(scope) } override fun onLoadMoreMedia() { @@ -837,6 +856,10 @@ class DefaultProfileComponent( _state.update { it.copy(miniAppUrl = null, miniAppName = null) } } + override fun onShareToStory(mediaUrl: String, text: String?, widgetLink: String?) { + onShareToStoryRequested(mediaUrl, text, widgetLink) + } + override fun onToggleMute() { val chat = _state.value.chat ?: return val shouldMute = !chat.isMuted @@ -1160,6 +1183,19 @@ class DefaultProfileComponent( _state.update { it.copy(selectedLocation = null) } } + override fun onOpenStories() { + val firstStoryId = _state.value.activeStoryList?.stories?.firstOrNull()?.storyId + onOpenStoriesClicked(chatId, firstStoryId) + } + + override fun onOpenStoryArchive() { + onOpenStoryArchiveClicked(chatId) + } + + override fun onCreateStory() { + onCreateStoryClicked(chatId) + } + private suspend fun enrichInteractionPreviews(stats: ChatStatisticsModel): ChatStatisticsModel { if (stats.recentInteractions.isEmpty()) return stats val enriched = stats.recentInteractions.map { interaction -> diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt index fec13679d..f498fb878 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt @@ -9,6 +9,7 @@ import org.monogram.domain.models.ChatStatisticsModel import org.monogram.domain.models.GroupMemberModel import org.monogram.domain.models.MessageModel import org.monogram.domain.models.UserModel +import org.monogram.domain.models.stories.ActiveStoryListModel import org.monogram.domain.repository.ChatMemberStatus import org.monogram.domain.repository.MessageRepository import org.monogram.presentation.core.util.IDownloadUtils @@ -42,6 +43,7 @@ interface ProfileComponent { fun onLoadMoreMedia() fun onOpenMiniApp(url: String, name: String, chatId: Long) fun onDismissMiniApp() + fun onShareToStory(mediaUrl: String, text: String?, widgetLink: String?) fun onToggleMute() fun onEdit() fun onShowQRCode() @@ -83,6 +85,9 @@ interface ProfileComponent { fun onLocationClick(lat: Double, lon: Double, address: String) fun onDismissLocation() + fun onOpenStories() + fun onOpenStoryArchive() + fun onCreateStory() data class State( val chatId: Long, @@ -158,7 +163,8 @@ interface ProfileComponent { val pendingMiniAppUrl: String? = null, val pendingMiniAppName: String? = null, - val selectedLocation: LocationData? = null + val selectedLocation: LocationData? = null, + val activeStoryList: ActiveStoryListModel? = null ) { val selectedTab: ProfileTabSpec? get() = visibleTabs.firstOrNull { it.key == selectedTabKey } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt index 3cec27cb2..3165c3581 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -23,12 +24,19 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ExitToApp +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Archive import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -98,6 +106,7 @@ fun ProfileContent(component: ProfileComponent) { val chat = state.chat val user = state.user val isCurrentUserProfile = user != null && state.currentUser?.id == user.id + val canManageStories = isCurrentUserProfile || chat?.isAdmin == true val isInitialLoading = state.isLoading && chat == null && user == null val avatarPath = remember(state.chat, state.user, state.personalAvatarPath) { @@ -366,6 +375,16 @@ fun ProfileContent(component: ProfileComponent) { showLinkedChat = state.chatId < 0 ) } else { + if (canManageStories || state.activeStoryList != null) { + ProfileStoriesCard( + canManageStories = canManageStories, + storyCount = state.activeStoryList?.stories?.size ?: 0, + onOpenStories = component::onOpenStories, + onOpenArchive = component::onOpenStoryArchive, + onCreateStory = component::onCreateStory + ) + Spacer(modifier = Modifier.height(12.dp)) + } ProfileInfoSection( state = state, localClipboard = localClipboard, @@ -506,6 +525,77 @@ fun ProfileContent(component: ProfileComponent) { } } +@Composable +private fun ProfileStoriesCard( + canManageStories: Boolean, + storyCount: Int, + onOpenStories: () -> Unit, + onOpenArchive: () -> Unit, + onCreateStory: () -> Unit +) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.profile_feature_stories_title), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = if (storyCount > 0) { + stringResource(R.string.story_profile_count, storyCount) + } else { + stringResource(R.string.profile_feature_stories_subtitle) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (storyCount > 0) { + FilterChip( + selected = false, + onClick = onOpenStories, + label = { Text(text = stringResource(R.string.story_open)) }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null + ) + } + ) + } + if (canManageStories) { + FilterChip( + selected = false, + onClick = onCreateStory, + label = { Text(text = stringResource(R.string.story_create)) }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = null + ) + } + ) + FilterChip( + selected = false, + onClick = onOpenArchive, + label = { Text(text = stringResource(R.string.story_archive)) }, + leadingIcon = { + Icon(Icons.Rounded.Archive, contentDescription = null) + } + ) + } + } + } + } +} + @Composable private fun ProfileHeaderSkeleton( progress: Float, diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt index 912c29af0..8dbb1f235 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt @@ -145,6 +145,7 @@ private fun MiniAppOverlay(state: ProfileComponent.State, component: ProfileComp botName = title, botAvatarPath = state.chat?.avatarPath ?: state.user?.avatarPath, webAppRepository = component.messageRepository, + onShareToStory = component::onShareToStory, onDismiss = { component.onDismissMiniApp() } ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt new file mode 100644 index 000000000..d27bf5839 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt @@ -0,0 +1,595 @@ +package org.monogram.presentation.features.stories + +import android.util.Log +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.monogram.domain.models.stories.ActiveStoryListModel +import org.monogram.domain.models.stories.StoryComposerDraftModel +import org.monogram.domain.models.stories.StoryListType +import org.monogram.domain.models.stories.StoryMediaType +import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryPrivacyMode +import org.monogram.domain.models.stories.StoryPrivacySettingsModel +import org.monogram.domain.repository.AuthRepository +import org.monogram.domain.repository.AuthStep +import org.monogram.domain.repository.ChatListRepository +import org.monogram.domain.repository.StoryRepository +import org.monogram.domain.repository.UserRepository +import org.monogram.presentation.core.util.componentScope +import org.monogram.presentation.root.AppComponentContext + +class DefaultStoriesHostComponent( + context: AppComponentContext +) : StoriesHostComponent, AppComponentContext by context { + private val authRepository: AuthRepository = container.repositories.authRepository + private val storyRepository: StoryRepository = container.repositories.storyRepository + private val chatListRepository: ChatListRepository = container.repositories.chatListRepository + private val userRepository: UserRepository = container.repositories.userRepository + private val messageDisplayer = container.utils.messageDisplayer() + private val scope = componentScope + private var hasLoadedActiveStories = false + private var storyLoadJob: Job? = null + private var storyRefreshJob: Job? = null + private val chatPresentationCache = mutableMapOf() + + private val _state = MutableStateFlow(StoriesHostComponent.State()) + override val state = _state.asStateFlow() + + init { + scope.launch { + Log.d(TAG, "initializing stories host") + authRepository.authState + .collect { authState -> + when (authState) { + is AuthStep.Ready -> { + if (!hasLoadedActiveStories) { + hasLoadedActiveStories = true + Log.d(TAG, "auth ready, loading active stories") + storyRepository.loadActiveStories(StoryListType.MAIN) + storyRepository.loadActiveStories(StoryListType.ARCHIVE) + } + } + + else -> { + hasLoadedActiveStories = false + } + } + } + } + } + + override fun openChatStories(chatId: Long, storyId: Int?, listType: StoryListType) { + storyLoadJob?.cancel() + storyRefreshJob?.cancel() + _state.value = _state.value.copy( + mode = StoriesHostComponent.Mode.Viewer, + isLoading = true, + chatId = chatId, + chatTitle = "", + chatAvatarPath = null, + viewerItems = emptyList(), + viewerIndex = 0, + currentStory = null, + activeListType = listType, + canManageStories = false, + inlineError = null, + showInlineVideo = false + ) + Log.d(TAG, "viewer placeholder shown chatId=$chatId listType=$listType") + storyLoadJob = scope.launch { + Log.d(TAG, "openChatStories chatId=$chatId storyId=$storyId listType=$listType") + + val activeStories = storyRepository.activeStories.value[listType] + .orEmpty() + .let { loadedStories -> + if (loadedStories.any { it.chatId == chatId }) { + loadedStories + } else { + loadedStories + listOfNotNull( + storyRepository.getChatActiveStories(chatId) + ?.takeIf { it.listType == listType } + ) + } + } + val items = buildViewerItems(activeStories) + + if (items.isEmpty()) { + val title = resolveChatTitle(chatId) + _state.value = _state.value.copy( + mode = StoriesHostComponent.Mode.Hidden, + isLoading = false, + inlineError = null + ) + messageDisplayer.show("No stories available for $title") + return@launch + } + + val resolvedIndex = resolveInitialViewerIndex(items, chatId, storyId) + val item = items[resolvedIndex] + val story = loadStory(item) + val chatPresentation = resolveChatPresentation(item.chatId) + + _state.value = _state.value.copy( + mode = StoriesHostComponent.Mode.Viewer, + isLoading = story.requiresMediaRefresh(), + chatId = item.chatId, + chatTitle = chatPresentation.title, + chatAvatarPath = chatPresentation.avatarPath, + viewerItems = items, + viewerIndex = resolvedIndex, + currentStory = story, + canManageStories = chatPresentation.canManageStories, + inlineError = null, + showInlineVideo = false + ) + scheduleStoryRefreshIfNeeded(item, story) + } + } + + override fun openStoryAlbum(chatId: Long, albumId: Int) { + storyRefreshJob?.cancel() + _state.value = _state.value.copy( + mode = StoriesHostComponent.Mode.Viewer, + isLoading = true, + chatId = chatId, + chatTitle = "", + chatAvatarPath = null, + viewerItems = emptyList(), + viewerIndex = 0, + currentStory = null, + activeListType = StoryListType.MAIN, + canManageStories = false, + inlineError = null, + showInlineVideo = false + ) + Log.d(TAG, "viewer placeholder shown for album chatId=$chatId albumId=$albumId") + scope.launch { + Log.d(TAG, "openStoryAlbum chatId=$chatId albumId=$albumId") + + val stories = storyRepository.getStoryAlbum(chatId, albumId) + val items = + stories.map { StoryViewerUiModel(chatId = chatId, storyId = it.id, date = it.date) } + if (items.isEmpty()) { + _state.value = + _state.value.copy(mode = StoriesHostComponent.Mode.Hidden, isLoading = false) + messageDisplayer.show("Story album is empty") + return@launch + } + + _state.value = _state.value.copy( + mode = StoriesHostComponent.Mode.Viewer, + isLoading = stories.first().requiresMediaRefresh(), + chatId = chatId, + chatTitle = resolveChatTitle(chatId), + chatAvatarPath = resolveChatAvatar(chatId), + viewerItems = items, + viewerIndex = 0, + currentStory = stories.first(), + canManageStories = canManageStories(chatId), + inlineError = null, + showInlineVideo = false + ) + scheduleStoryRefreshIfNeeded(items.first(), stories.first()) + } + } + + override fun openComposer( + chatId: Long, + preferredMediaType: StoryMediaType?, + initialSourcePath: String?, + initialCaption: String, + widgetLink: String? + ) { + storyRefreshJob?.cancel() + _state.value = _state.value.copy( + mode = StoriesHostComponent.Mode.Composer, + isLoading = true, + chatId = chatId, + chatTitle = "", + chatAvatarPath = null, + canManageStories = false, + composerDraft = StoryComposerDraftModel( + sourcePath = initialSourcePath.orEmpty(), + mediaType = preferredMediaType ?: inferMediaType(initialSourcePath), + caption = initialCaption, + widgetLink = widgetLink + ), + postCapability = null, + inlineError = null, + showMediaPicker = initialSourcePath.isNullOrBlank(), + showCamera = false, + isSubmitting = false, + showInlineVideo = false + ) + Log.d(TAG, "composer placeholder shown chatId=$chatId") + scope.launch { + val capability = storyRepository.canPostStory(chatId) + Log.d(TAG, "openComposer chatId=$chatId capability=$capability") + _state.value = _state.value.copy( + mode = StoriesHostComponent.Mode.Composer, + isLoading = false, + chatId = chatId, + chatTitle = resolveChatTitle(chatId), + chatAvatarPath = resolveChatAvatar(chatId), + canManageStories = canManageStories(chatId), + composerDraft = StoryComposerDraftModel( + sourcePath = initialSourcePath.orEmpty(), + mediaType = preferredMediaType ?: inferMediaType(initialSourcePath), + caption = initialCaption, + widgetLink = widgetLink + ), + postCapability = capability, + inlineError = null, + showMediaPicker = initialSourcePath.isNullOrBlank(), + showCamera = false, + isSubmitting = false, + showInlineVideo = false + ) + } + } + + override fun dismiss() { + storyLoadJob?.cancel() + storyRefreshJob?.cancel() + scope.launch { + state.value.currentStory?.let { + storyRepository.closeStory(it.posterChatId, it.id) + } + } + _state.value = StoriesHostComponent.State() + } + + override fun nextStory() { + val current = _state.value + if (!current.canGoNext) return + openStoryAt(current.viewerIndex + 1) + } + + override fun previousStory() { + val current = _state.value + if (!current.canGoPrevious) return + openStoryAt(current.viewerIndex - 1) + } + + override fun openMediaPicker() { + _state.value = _state.value.copy(showMediaPicker = true, showCamera = false) + } + + override fun dismissMediaPicker() { + _state.value = _state.value.copy(showMediaPicker = false) + } + + override fun showCamera() { + _state.value = _state.value.copy(showMediaPicker = false, showCamera = true) + } + + override fun dismissCamera() { + _state.value = _state.value.copy(showCamera = false) + } + + override fun attachMedia(path: String, mediaType: StoryMediaType) { + _state.value = _state.value.copy( + composerDraft = _state.value.composerDraft.copy( + sourcePath = path, + mediaType = mediaType + ), + showMediaPicker = false, + showCamera = false, + inlineError = null + ) + } + + override fun updateCaption(caption: String) { + _state.value = _state.value.copy( + composerDraft = _state.value.composerDraft.copy(caption = caption) + ) + } + + override fun updatePrivacy(mode: StoryPrivacyUi) { + _state.value = _state.value.copy( + composerDraft = _state.value.composerDraft.copy( + privacy = StoryPrivacySettingsModel( + mode = when (mode) { + StoryPrivacyUi.EVERYONE -> StoryPrivacyMode.EVERYONE + StoryPrivacyUi.CONTACTS -> StoryPrivacyMode.CONTACTS + StoryPrivacyUi.CLOSE_FRIENDS -> StoryPrivacyMode.CLOSE_FRIENDS + } + ) + ) + ) + } + + override fun updateActivePeriod(seconds: Int) { + _state.value = _state.value.copy( + composerDraft = _state.value.composerDraft.copy(activePeriodSeconds = seconds) + ) + } + + override fun updateProtectContent(protectContent: Boolean) { + _state.value = _state.value.copy( + composerDraft = _state.value.composerDraft.copy(protectContent = protectContent) + ) + } + + override fun updateKeepOnProfile(keepOnProfile: Boolean) { + _state.value = _state.value.copy( + composerDraft = _state.value.composerDraft.copy(keepOnProfile = keepOnProfile) + ) + } + + override fun submitStory() { + val current = _state.value + val chatId = current.chatId ?: return + if (!current.composerDraft.isValid) { + _state.value = current.copy(inlineError = "Pick a photo or video first") + return + } + + _state.value = current.copy(isSubmitting = true, inlineError = null) + scope.launch { + Log.d(TAG, "submitStory chatId=$chatId mediaType=${current.composerDraft.mediaType}") + when (val result = storyRepository.postStory(chatId, _state.value.composerDraft)) { + is org.monogram.domain.models.stories.StoryPostResultModel.Success -> { + Log.d(TAG, "submitStory success chatId=$chatId storyId=${result.story.id}") + _state.value = StoriesHostComponent.State() + openChatStories(chatId, result.story.id) + } + + is org.monogram.domain.models.stories.StoryPostResultModel.Failure -> { + Log.d(TAG, "submitStory failed chatId=$chatId message=${result.message}") + _state.value = _state.value.copy( + isSubmitting = false, + inlineError = result.message.ifBlank { "Failed to publish story" } + ) + } + } + } + } + + override fun deleteCurrentStory() { + val story = _state.value.currentStory ?: return + scope.launch { + val deleted = storyRepository.deleteStory(story.posterChatId, story.id) + if (!deleted) { + _state.value = _state.value.copy(inlineError = "Failed to delete story") + return@launch + } + val remaining = _state.value.viewerItems.filterNot { + it.chatId == story.posterChatId && it.storyId == story.id + } + if (remaining.isEmpty()) { + dismiss() + } else { + val nextIndex = _state.value.viewerIndex.coerceAtMost(remaining.lastIndex) + _state.value = _state.value.copy(viewerItems = remaining, viewerIndex = nextIndex) + openStoryAt(nextIndex) + } + } + } + + override fun moveCurrentStoryToArchive() { + val chatId = _state.value.chatId ?: return + scope.launch { + if (storyRepository.setChatActiveStoriesList(chatId, StoryListType.ARCHIVE)) { + _state.value = _state.value.copy(activeListType = StoryListType.ARCHIVE) + storyRepository.loadActiveStories(StoryListType.ARCHIVE) + } else { + _state.value = _state.value.copy(inlineError = "Failed to archive stories") + } + } + } + + override fun restoreCurrentStoryFromArchive() { + val chatId = _state.value.chatId ?: return + scope.launch { + if (storyRepository.setChatActiveStoriesList(chatId, StoryListType.MAIN)) { + _state.value = _state.value.copy(activeListType = StoryListType.MAIN) + storyRepository.loadActiveStories(StoryListType.MAIN) + } else { + _state.value = _state.value.copy(inlineError = "Failed to restore stories") + } + } + } + + override fun dismissInlineVideo() { + _state.value = _state.value.copy(showInlineVideo = false) + } + + override fun showInlineVideo() { + _state.value = _state.value.copy(showInlineVideo = true) + } + + private fun openStoryAt(index: Int) { + val current = _state.value + val item = current.viewerItems.getOrNull(index) ?: return + storyLoadJob?.cancel() + storyRefreshJob?.cancel() + val cachedPresentation = chatPresentationCache[item.chatId] + _state.value = current.copy( + viewerIndex = index, + chatId = item.chatId, + chatTitle = cachedPresentation?.title + ?: if (item.chatId == current.chatId) current.chatTitle else "", + chatAvatarPath = cachedPresentation?.avatarPath + ?: if (item.chatId == current.chatId) current.chatAvatarPath else null, + currentStory = null, + isLoading = true, + canManageStories = cachedPresentation?.canManageStories ?: false, + inlineError = null, + showInlineVideo = false + ) + current.currentStory?.let { previousStory -> + scope.launch { + storyRepository.closeStory(previousStory.posterChatId, previousStory.id) + } + } + storyLoadJob = scope.launch { + val story = loadStory(item) + val chatPresentation = resolveChatPresentation(item.chatId) + _state.value = _state.value.copy( + chatId = item.chatId, + chatTitle = chatPresentation.title, + chatAvatarPath = chatPresentation.avatarPath, + viewerIndex = index, + currentStory = story, + isLoading = story.requiresMediaRefresh(), + canManageStories = chatPresentation.canManageStories, + inlineError = if (story == null) "Unable to load story" else null, + showInlineVideo = false + ) + scheduleStoryRefreshIfNeeded(item, story) + } + } + + private suspend fun loadStory(item: StoryViewerUiModel): StoryModel? { + val story = storyRepository.getStory(item.chatId, item.storyId) + if (story != null) { + Log.d( + TAG, + "loadStory chatId=${item.chatId} storyId=${item.storyId} success mediaType=${story.media.type} path=${story.media.path} preview=${story.media.previewPath} minithumbnail=${story.media.minithumbnail != null}" + ) + storyRepository.openStory(item.chatId, item.storyId) + } else { + Log.d(TAG, "loadStory chatId=${item.chatId} storyId=${item.storyId} returned null") + } + return story + } + + private fun scheduleStoryRefreshIfNeeded(item: StoryViewerUiModel, story: StoryModel?) { + if (!story.requiresMediaRefresh()) return + storyRefreshJob?.cancel() + storyRefreshJob = scope.launch { + Log.d(TAG, "scheduleStoryRefresh chatId=${item.chatId} storyId=${item.storyId}") + val delays = longArrayOf(150L, 350L, 700L, 1200L, 1800L, 2600L) + for ((attempt, waitMs) in delays.withIndex()) { + delay(waitMs) + val current = _state.value + if ( + current.mode != StoriesHostComponent.Mode.Viewer || + current.chatId != item.chatId || + current.viewerItems.getOrNull(current.viewerIndex)?.storyId != item.storyId + ) { + Log.d( + TAG, + "scheduleStoryRefresh cancelled due to story switch chatId=${item.chatId} storyId=${item.storyId}" + ) + return@launch + } + val refreshed = storyRepository.getStory(item.chatId, item.storyId) + Log.d( + TAG, + "storyRefresh attempt=${attempt + 1} chatId=${item.chatId} storyId=${item.storyId} path=${refreshed?.media?.path} preview=${refreshed?.media?.previewPath} mini=${refreshed?.media?.minithumbnail != null}" + ) + if (refreshed != null) { + _state.value = _state.value.copy( + currentStory = refreshed, + isLoading = refreshed.requiresMediaRefresh(), + inlineError = null + ) + } + if (refreshed?.requiresMediaRefresh() == false) { + return@launch + } + } + + val current = _state.value + if ( + current.mode == StoriesHostComponent.Mode.Viewer && + current.chatId == item.chatId && + current.viewerItems.getOrNull(current.viewerIndex)?.storyId == item.storyId && + current.currentStory.requiresMediaRefresh() + ) { + _state.value = current.copy( + isLoading = false, + inlineError = "Media is still loading" + ) + } + } + } + + private fun StoryModel?.requiresMediaRefresh(): Boolean { + return this != null && + media.path.isNullOrBlank() && + media.previewPath.isNullOrBlank() + } + + private suspend fun resolveChatPresentation(chatId: Long): ChatPresentation { + chatPresentationCache[chatId]?.let { return it } + + val presentation = ChatPresentation( + title = resolveChatTitle(chatId), + avatarPath = resolveChatAvatar(chatId), + canManageStories = canManageStories(chatId) + ) + chatPresentationCache[chatId] = presentation + return presentation + } + + private suspend fun resolveChatTitle(chatId: Long): String { + return chatListRepository.getChatById(chatId)?.title ?: chatId.toString() + } + + private suspend fun resolveChatAvatar(chatId: Long): String? { + return chatListRepository.getChatById(chatId)?.avatarPath + } + + private suspend fun canManageStories(chatId: Long): Boolean { + val me = userRepository.getMe() + val chat = chatListRepository.getChatById(chatId) + return me?.id == chatId || chat?.isAdmin == true + } + + private fun inferMediaType(sourcePath: String?): StoryMediaType { + val normalized = sourcePath.orEmpty().lowercase() + return if ( + normalized.endsWith(".mp4") || + normalized.endsWith(".mov") || + normalized.endsWith(".webm") || + normalized.endsWith(".mkv") + ) { + StoryMediaType.VIDEO + } else { + StoryMediaType.PHOTO + } + } + + companion object { + private const val TAG = "StoriesHostDiag" + } +} + +internal fun buildViewerItems(activeStories: List): List { + return activeStories.flatMap { activeStoriesForChat -> + activeStoriesForChat.stories.map { storySummary -> + StoryViewerUiModel( + chatId = activeStoriesForChat.chatId, + storyId = storySummary.storyId, + date = storySummary.date + ) + } + } +} + +internal fun resolveInitialViewerIndex( + items: List, + chatId: Long, + storyId: Int? +): Int { + if (items.isEmpty()) return 0 + + val exactIndex = + items.indexOfFirst { it.chatId == chatId && (storyId == null || it.storyId == storyId) } + if (exactIndex >= 0) return exactIndex + + val chatIndex = items.indexOfFirst { it.chatId == chatId } + return if (chatIndex >= 0) chatIndex else 0 +} + +private data class ChatPresentation( + val title: String, + val avatarPath: String?, + val canManageStories: Boolean +) diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt new file mode 100644 index 000000000..95c97f89c --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt @@ -0,0 +1,102 @@ +package org.monogram.presentation.features.stories + +import kotlinx.coroutines.flow.StateFlow +import org.monogram.domain.models.stories.ActiveStoryListModel +import org.monogram.domain.models.stories.StoryComposerDraftModel +import org.monogram.domain.models.stories.StoryListType +import org.monogram.domain.models.stories.StoryMediaType +import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryPostCapabilityModel + +interface StoriesHostComponent { + val state: StateFlow + + fun openChatStories( + chatId: Long, + storyId: Int? = null, + listType: StoryListType = StoryListType.MAIN + ) + + fun openStoryAlbum(chatId: Long, albumId: Int) + fun openComposer( + chatId: Long, + preferredMediaType: StoryMediaType? = null, + initialSourcePath: String? = null, + initialCaption: String = "", + widgetLink: String? = null + ) + + fun dismiss() + fun nextStory() + fun previousStory() + fun openMediaPicker() + fun dismissMediaPicker() + fun showCamera() + fun dismissCamera() + fun attachMedia(path: String, mediaType: StoryMediaType) + fun updateCaption(caption: String) + fun updatePrivacy(mode: StoryPrivacyUi) + fun updateActivePeriod(seconds: Int) + fun updateProtectContent(protectContent: Boolean) + fun updateKeepOnProfile(keepOnProfile: Boolean) + fun submitStory() + fun deleteCurrentStory() + fun moveCurrentStoryToArchive() + fun restoreCurrentStoryFromArchive() + fun dismissInlineVideo() + fun showInlineVideo() + + data class State( + val mode: Mode = Mode.Hidden, + val isLoading: Boolean = false, + val chatId: Long? = null, + val chatTitle: String = "", + val chatAvatarPath: String? = null, + val viewerItems: List = emptyList(), + val viewerIndex: Int = 0, + val currentStory: StoryModel? = null, + val activeListType: StoryListType = StoryListType.MAIN, + val canManageStories: Boolean = false, + val composerDraft: StoryComposerDraftModel = StoryComposerDraftModel(), + val postCapability: StoryPostCapabilityModel? = null, + val inlineError: String? = null, + val isSubmitting: Boolean = false, + val showMediaPicker: Boolean = false, + val showCamera: Boolean = false, + val showInlineVideo: Boolean = false + ) { + val isVisible: Boolean + get() = mode != Mode.Hidden + + val canGoPrevious: Boolean + get() = viewerIndex > 0 + + val canGoNext: Boolean + get() = viewerIndex >= 0 && viewerIndex < viewerItems.lastIndex + } + + enum class Mode { + Hidden, + Viewer, + Composer + } +} + +data class StoryViewerUiModel( + val chatId: Long, + val storyId: Int, + val date: Int +) + +enum class StoryPrivacyUi { + EVERYONE, + CONTACTS, + CLOSE_FRIENDS +} + +data class StoryStripItemUiModel( + val chatId: Long, + val title: String, + val avatarPath: String?, + val activeStories: ActiveStoryListModel +) diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt new file mode 100644 index 000000000..6fc12a4f4 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt @@ -0,0 +1,2085 @@ +package org.monogram.presentation.features.stories + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.PackageManager +import android.net.Uri +import android.text.format.DateFormat +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.VolumeOff +import androidx.compose.material.icons.automirrored.rounded.VolumeUp +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Archive +import androidx.compose.material.icons.rounded.CameraAlt +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PeopleAlt +import androidx.compose.material.icons.rounded.PhotoLibrary +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Public +import androidx.compose.material.icons.rounded.Restore +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.Shield +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.zIndex +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import kotlinx.coroutines.delay +import org.monogram.domain.models.stories.StoryListType +import org.monogram.domain.models.stories.StoryMediaType +import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryPostCapabilityModel +import org.monogram.domain.models.stories.StoryPrivacyMode +import org.monogram.presentation.R +import org.monogram.presentation.features.camera.CameraScreen +import org.monogram.presentation.features.chats.conversation.ui.inputbar.copyUriToTempPath +import org.monogram.presentation.features.chats.conversation.ui.inputbar.declaredPermissions +import org.monogram.presentation.features.chats.conversation.ui.inputbar.hasAllPermissions +import org.monogram.presentation.features.gallery.GalleryScreen +import org.monogram.presentation.features.viewers.VideoViewer +import java.io.File +import java.util.Date +import kotlin.math.roundToInt + +@Composable +fun StoriesHostContent(component: StoriesHostComponent) { + val state by component.state.collectAsState() + if (!state.isVisible) return + + BackHandler(enabled = state.isVisible) { + when { + state.showInlineVideo -> component.dismissInlineVideo() + state.showCamera -> component.dismissCamera() + state.showMediaPicker -> component.dismissMediaPicker() + else -> component.dismiss() + } + } + + StoriesOverlay(state = state, component = component) +} + +@Composable +fun StoriesStrip( + items: List, + onStoryClick: (Long, Int?) -> Unit, + modifier: Modifier = Modifier +) { + if (items.isEmpty()) return + + LazyRow( + modifier = modifier, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(items, key = { it.chatId }) { item -> + StoryStripTile( + title = item.title, + avatarPath = item.avatarPath, + hasUnread = item.activeStories.stories.any { !it.isRead }, + onClick = { + onStoryClick( + item.chatId, + item.activeStories.stories.firstOrNull()?.storyId + ) + } + ) + } + } +} + +internal fun shouldShowStoriesStrip( + selectedFolderId: Int, + isSearchActive: Boolean +): Boolean { + return !isSearchActive && (selectedFolderId == -1 || selectedFolderId == -2) +} + +@Composable +private fun StoriesOverlay( + state: StoriesHostComponent.State, + component: StoriesHostComponent +) { + AnimatedVisibility( + visible = state.isVisible, + modifier = Modifier + .fillMaxSize() + .zIndex(200f), + enter = fadeIn(tween(180)) + + scaleIn( + initialScale = 0.96f, + animationSpec = spring(dampingRatio = 0.92f, stiffness = 420f) + ) + + slideInVertically( + initialOffsetY = { it / 10 }, + animationSpec = tween(220, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(160)) + + scaleOut( + targetScale = 0.98f, + animationSpec = tween(160, easing = FastOutSlowInEasing) + ) + + slideOutVertically( + targetOffsetY = { it / 14 }, + animationSpec = tween(180, easing = FastOutSlowInEasing) + ) + ) { + AnimatedContent( + targetState = state.mode, + transitionSpec = { + fadeIn(tween(180)) togetherWith fadeOut(tween(120)) + }, + label = "stories_mode_switch" + ) { mode -> + when (mode) { + StoriesHostComponent.Mode.Viewer -> StoryViewerOverlay(state, component) + StoriesHostComponent.Mode.Composer -> StoryComposerOverlay(state, component) + StoriesHostComponent.Mode.Hidden -> Unit + } + } + } +} + +@Composable +private fun StoryViewerOverlay( + state: StoriesHostComponent.State, + component: StoriesHostComponent +) { + val story = state.currentStory + val videoPath = story?.media?.path + + Surface( + modifier = Modifier.fillMaxSize(), + color = Color.Black, + contentColor = MaterialTheme.colorScheme.onPrimary + ) { + Box(modifier = Modifier.fillMaxSize()) { + if ( + story?.media?.type == StoryMediaType.VIDEO && + state.showInlineVideo && + !videoPath.isNullOrBlank() + ) { + VideoViewer( + path = videoPath, + onDismiss = component::dismissInlineVideo, + downloadUtils = org.koin.compose.koinInject() + ) + } else { + StoryViewerScaffold(state = state, component = component, story = story) + } + } + } +} + +@Composable +private fun StoryViewerScaffold( + state: StoriesHostComponent.State, + component: StoriesHostComponent, + story: StoryModel? +) { + val context = LocalContext.current + val downloadUtils: org.monogram.presentation.core.util.IDownloadUtils = + org.koin.compose.koinInject() + var currentProgress by remember(story?.id) { mutableFloatStateOf(0f) } + var restartPlaybackToken by remember(story?.id) { mutableStateOf(0) } + var isVideoMuted by remember(story?.id) { mutableStateOf(false) } + var isVideoPaused by remember(story?.id) { mutableStateOf(false) } + var isVideoBuffering by remember(story?.id) { mutableStateOf(story?.media?.type == StoryMediaType.VIDEO) } + var isVideoPlaying by remember(story?.id) { mutableStateOf(false) } + val advanceStory by rememberUpdatedState(newValue = { + if (state.canGoNext) { + component.nextStory() + } else { + component.dismiss() + } + }) + + LaunchedEffect(story?.id, restartPlaybackToken, state.isLoading, story?.media?.type) { + currentProgress = 0f + if (story == null || state.isLoading || story.media.type != StoryMediaType.PHOTO) return@LaunchedEffect + val totalDurationMs = resolveStoryAutoAdvanceDurationMs(story) + val startMs = System.currentTimeMillis() + while (currentProgress < 1f) { + val elapsedMs = (System.currentTimeMillis() - startMs).coerceAtLeast(0L) + currentProgress = (elapsedMs.toFloat() / totalDurationMs.toFloat()).coerceIn(0f, 1f) + if (currentProgress >= 1f) break + delay(16) + } + advanceStory() + } + + val pageState = remember(story, state.viewerIndex, state.isLoading, state.inlineError) { + StoryViewerPageState( + story = story, + viewerIndex = state.viewerIndex, + isLoading = state.isLoading, + inlineError = state.inlineError + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + StoryViewerSystemBars() + StoryViewerBackground() + StoryStatusBarScrim() + + AnimatedContent( + targetState = pageState, + transitionSpec = { + fadeIn(tween(110)) togetherWith fadeOut(tween(90)) + }, + contentKey = { current -> current.story?.id ?: "story-${current.viewerIndex}" }, + label = "story_viewer_page" + ) { currentPage -> + StoryMediaScene( + context = context, + state = state, + page = currentPage, + progress = currentProgress, + isVideoMuted = isVideoMuted, + isVideoPaused = isVideoPaused, + restartPlaybackToken = restartPlaybackToken, + onVideoMutedChange = { isVideoMuted = it }, + onVideoPausedChange = { isVideoPaused = it }, + onVideoProgress = { progressValue -> + currentProgress = progressValue.coerceIn(0f, 1f) + }, + onVideoBufferingChange = { isVideoBuffering = it }, + onVideoPlayingChange = { isVideoPlaying = it }, + onVideoCompleted = advanceStory + ) + } + + Row(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable( + enabled = story != null, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { + if (shouldRestartCurrentStoryFromPreviousTap(currentProgress) || !state.canGoPrevious) { + restartPlaybackToken += 1 + isVideoPaused = false + } else { + component.previousStory() + } + }) + ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable( + enabled = story != null, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { + if (state.canGoNext) component.nextStory() else component.dismiss() + }) + ) + } + + StoryViewerChrome( + state = state, + story = story, + progress = currentProgress, + isVideo = story?.media?.type == StoryMediaType.VIDEO, + isVideoPaused = isVideoPaused, + isVideoMuted = isVideoMuted, + isVideoBuffering = isVideoBuffering, + isVideoPlaying = isVideoPlaying, + onBack = component::dismiss, + onArchive = component::moveCurrentStoryToArchive, + onRestore = component::restoreCurrentStoryFromArchive, + onDelete = component::deleteCurrentStory, + onPauseToggle = { isVideoPaused = !isVideoPaused }, + onMuteToggle = { isVideoMuted = !isVideoMuted }, + onDownload = { + resolveStoryDownloadPath(story)?.let(downloadUtils::saveFileToDownloads) + } + ) + } +} + +@Composable +private fun StoryViewerBackground() { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color(0xFF090A0E), + Color(0xFF10131B), + Color(0xFF06070B) + ) + ) + ) + ) +} + +@Composable +private fun StoryMediaScene( + context: Context, + state: StoriesHostComponent.State, + page: StoryViewerPageState, + progress: Float, + isVideoMuted: Boolean, + isVideoPaused: Boolean, + restartPlaybackToken: Int, + onVideoMutedChange: (Boolean) -> Unit, + onVideoPausedChange: (Boolean) -> Unit, + onVideoProgress: (Float) -> Unit, + onVideoBufferingChange: (Boolean) -> Unit, + onVideoPlayingChange: (Boolean) -> Unit, + onVideoCompleted: () -> Unit +) { + val story = page.story + when { + story == null && page.isLoading -> { + StoryMediaWaitingPlaceholder() + } + + story == null -> { + StoryUnavailablePlaceholder( + page.inlineError ?: stringResource(R.string.story_viewer_unavailable) + ) + } + + story.media.type == StoryMediaType.PHOTO -> { + val storyImageModel = rememberStoryImageModel( + context = context, + primaryPath = story.media.path, + fallbackPath = story.media.previewPath, + minithumbnail = story.media.minithumbnail + ) + if (storyImageModel != null) { + AsyncImage( + model = storyImageModel, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + if (page.isLoading) { + StoryMediaLoadingOverlay() + } + } else if (page.isLoading) { + StoryMediaWaitingPlaceholder() + } else { + StoryUnavailablePlaceholder( + page.inlineError ?: stringResource(R.string.story_viewer_unavailable) + ) + } + } + + else -> { + val previewModel = rememberStoryImageModel( + context = context, + primaryPath = story.media.previewPath, + fallbackPath = story.media.path, + minithumbnail = story.media.minithumbnail + ) + if (!story.media.path.isNullOrBlank()) { + StoryInlineVideoPlayer( + path = story.media.path.orEmpty(), + previewModel = previewModel, + isMuted = isVideoMuted, + isPlaying = !isVideoPaused, + restartPlaybackToken = restartPlaybackToken, + onProgress = onVideoProgress, + onBufferingChange = onVideoBufferingChange, + onPlayingChange = onVideoPlayingChange, + onCompleted = onVideoCompleted + ) + } else if (previewModel != null) { + AsyncImage( + model = previewModel, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + StoryMediaLoadingOverlay() + } else if (page.isLoading) { + StoryMediaWaitingPlaceholder() + } else { + StoryUnavailablePlaceholder( + page.inlineError ?: stringResource(R.string.story_viewer_unavailable) + ) + } + + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.40f), + Color.Transparent, + Color.Black.copy(alpha = 0.58f) + ) + ) + ) + ) +} + +@Composable +private fun StoryViewerChrome( + state: StoriesHostComponent.State, + story: StoryModel?, + progress: Float, + isVideo: Boolean, + isVideoPaused: Boolean, + isVideoMuted: Boolean, + isVideoBuffering: Boolean, + isVideoPlaying: Boolean, + onBack: () -> Unit, + onArchive: () -> Unit, + onRestore: () -> Unit, + onDelete: () -> Unit, + onPauseToggle: () -> Unit, + onMuteToggle: () -> Unit, + onDownload: () -> Unit +) { + val currentChatId = state.chatId + val currentChatItems = remember(state.viewerItems, currentChatId) { + state.viewerItems.filter { it.chatId == currentChatId } + } + val currentChatIndex = remember(state.viewerItems, currentChatId, state.viewerIndex) { + val currentItem = state.viewerItems.getOrNull(state.viewerIndex) + if (currentItem == null || currentChatId == null) { + 0 + } else { + currentChatItems.indexOfFirst { + it.chatId == currentItem.chatId && it.storyId == currentItem.storyId + }.coerceAtLeast(0) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + StoryViewerProgressRow( + total = currentChatItems.size, + currentIndex = currentChatIndex, + currentProgress = progress + ) + StoryViewerHeader( + state = state, + story = story, + onBack = onBack, + onArchive = onArchive, + onRestore = onRestore, + onDelete = onDelete + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + AnimatedVisibility(visible = isVideo) { + StoryTopIconButton( + onClick = onMuteToggle, + contentDescription = if (isVideoMuted) { + stringResource(R.string.menu_unmute) + } else { + stringResource(R.string.menu_mute) + } + ) { + Icon( + imageVector = if (isVideoMuted) { + Icons.AutoMirrored.Rounded.VolumeOff + } else { + Icons.AutoMirrored.Rounded.VolumeUp + }, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + AnimatedVisibility(visible = isVideo) { + StoryTopIconButton( + onClick = onPauseToggle, + contentDescription = if (isVideoPaused || !isVideoPlaying) { + stringResource(R.string.action_play) + } else { + stringResource(R.string.action_pause) + } + ) { + if (isVideoBuffering) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.5.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Icon( + imageVector = if (isVideoPaused || !isVideoPlaying) { + Icons.Rounded.PlayArrow + } else { + Icons.Rounded.Pause + }, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + } + StoryTopIconButton( + onClick = onDownload, + contentDescription = stringResource(R.string.action_download) + ) { + Icon( + imageVector = Icons.Rounded.Download, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + AnimatedVisibility(visible = state.inlineError != null) { + StoryErrorBanner(message = state.inlineError.orEmpty()) + } + + AnimatedVisibility(visible = !story?.caption.isNullOrBlank()) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = Color.Black.copy(alpha = 0.38f) + ) { + Text( + text = story?.caption.orEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary + ) + } + } + } + } +} + +@Composable +private fun StoryViewerHeader( + state: StoriesHostComponent.State, + story: StoryModel?, + onBack: () -> Unit, + onArchive: () -> Unit, + onRestore: () -> Unit, + onDelete: () -> Unit +) { + val context = LocalContext.current + val showSkeleton = state.chatTitle.isBlank() + val selectedItem = state.viewerItems.getOrNull(state.viewerIndex) + val headerInfo = remember( + state.chatId, + state.chatTitle, + selectedItem?.storyId, + selectedItem?.date, + state.viewerIndex, + state.viewerItems + ) { + StoryHeaderInfoState( + title = state.chatTitle, + storyId = selectedItem?.storyId, + positionText = buildStoryPositionText(state), + postedAt = selectedItem?.date?.let { formatStoryPostedTime(context, it) }.orEmpty() + ) + } + + Surface( + shape = RoundedCornerShape(22.dp), + color = Color.Black.copy(alpha = 0.30f) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Surface( + shape = CircleShape, + color = if (showSkeleton) Color.White.copy(alpha = 0.16f) else Color.White.copy( + alpha = 0.12f + ), + modifier = Modifier.size(38.dp) + ) { + if (showSkeleton) { + StorySkeletonPlaceholder() + } else if (state.chatAvatarPath != null) { + AsyncImage( + model = state.chatAvatarPath, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Image, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.9f) + ) + } + } + } + + Box(modifier = Modifier.weight(1f)) { + AnimatedContent( + targetState = headerInfo, + transitionSpec = { + (fadeIn(tween(180)) + slideInVertically { it / 4 }) togetherWith + (fadeOut(tween(120)) + slideOutVertically { -it / 4 }) + }, + label = "story_header_info" + ) { currentInfo -> + if (showSkeleton) { + StoryHeaderSkeleton() + } else { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = currentInfo.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = buildString { + append(currentInfo.positionText) + if (currentInfo.postedAt.isNotBlank()) { + append(" • ") + append(currentInfo.postedAt) + } + }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.72f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + + if (state.canManageStories) { + IconButton( + onClick = if (state.activeListType == StoryListType.MAIN) onArchive else onRestore + ) { + Icon( + imageVector = if (state.activeListType == StoryListType.MAIN) { + Icons.Rounded.Archive + } else { + Icons.Rounded.Restore + }, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + + if (story?.canBeDeleted == true) { + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + } +} + +@Composable +private fun StoryMetadataChip( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String +) { + AssistChip( + onClick = {}, + enabled = false, + label = { Text(label) }, + leadingIcon = { + Icon( + imageVector = icon, + contentDescription = null + ) + }, + colors = AssistChipDefaults.assistChipColors( + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.7f), + disabledLabelColor = MaterialTheme.colorScheme.onSurface, + disabledLeadingIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + border = AssistChipDefaults.assistChipBorder( + enabled = false, + borderColor = Color.Transparent + ) + ) +} + +@Composable +private fun StoryUnavailablePlaceholder(message: String) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Surface( + shape = RoundedCornerShape(30.dp), + color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.82f) + ) { + Text( + text = message, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} + +@Composable +private fun StoryErrorBanner(message: String) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.96f) + ) { + Text( + text = message, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } +} + +@Composable +private fun rememberStoryImageModel( + context: Context, + primaryPath: String?, + fallbackPath: String?, + minithumbnail: ByteArray? +): Any? { + val resolvedPath = remember(primaryPath, fallbackPath) { + listOfNotNull( + primaryPath?.takeIf { it.isNotBlank() }, + fallbackPath?.takeIf { it.isNotBlank() } + ).firstOrNull() + } + + if (resolvedPath == null && minithumbnail != null && minithumbnail.isNotEmpty()) { + return remember(minithumbnail) { + ImageRequest.Builder(context) + .data(minithumbnail) + .build() + } + } + + resolvedPath ?: return null + + return remember(resolvedPath) { + if ( + resolvedPath.startsWith("http://") || + resolvedPath.startsWith("https://") || + resolvedPath.startsWith("content:") || + resolvedPath.startsWith("file:") + ) { + resolvedPath + } else { + val file = File(resolvedPath) + if (file.exists()) { + ImageRequest.Builder(context) + .data(file) + .memoryCacheKey("${file.absolutePath}:${file.lastModified()}:${file.length()}") + .diskCacheKey("${file.absolutePath}:${file.lastModified()}:${file.length()}") + .build() + } else { + ImageRequest.Builder(context) + .data(resolvedPath) + .build() + } + } + } +} + +@Composable +private fun StoryMediaWaitingPlaceholder() { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.20f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.onPrimary) + } +} + +@Composable +private fun StoryMediaLoadingOverlay() { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.10f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + strokeWidth = 2.5.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } +} + +@Composable +private fun StoryStatusBarScrim() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.50f), + Color.Black.copy(alpha = 0.24f), + Color.Transparent + ) + ) + ) + ) +} + +@Composable +private fun StoryViewerProgressRow( + total: Int, + currentIndex: Int, + currentProgress: Float +) { + if (total <= 0) return + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + repeat(total) { index -> + val progress = when { + index < currentIndex -> 1f + index == currentIndex -> currentProgress.coerceIn(0f, 1f) + else -> 0f + } + Box( + modifier = Modifier + .weight(1f) + .height(4.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.22f)) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(progress) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onPrimary) + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun StoryComposerOverlay( + state: StoriesHostComponent.State, + component: StoriesHostComponent +) { + val context = LocalContext.current + val scrollState = rememberScrollState() + val capability = state.postCapability.toCapabilityPresentation() + + val galleryPermissions = remember { + when { + android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> listOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED + ) + + android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU -> listOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO + ) + + else -> listOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + val fullGalleryPermissions = remember { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + listOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO) + } else { + listOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + val requestableGalleryPermissions = remember(context, galleryPermissions) { + val declared = context.declaredPermissions() + galleryPermissions.filter { it in declared } + } + val requestableFullGalleryPermissions = remember(context, fullGalleryPermissions) { + val declared = context.declaredPermissions() + fullGalleryPermissions.filter { it in declared } + } + + fun hasPartialGalleryPermission(): Boolean { + return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED + ) == PackageManager.PERMISSION_GRANTED + } + + fun hasFullGalleryPermission(): Boolean { + return requestableFullGalleryPermissions.isEmpty() || + context.hasAllPermissions(requestableFullGalleryPermissions) + } + + var hasGalleryAccess by remember { + mutableStateOf(hasFullGalleryPermission() || hasPartialGalleryPermission()) + } + val galleryPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { + hasGalleryAccess = hasFullGalleryPermission() || hasPartialGalleryPermission() + } + + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) + } + val cameraPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + hasCameraPermission = granted + if (granted) { + component.showCamera() + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + TopAppBar( + title = { + Column { + Text( + text = state.chatTitle.ifBlank { stringResource(R.string.story_compose_title) }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = stringResource( + R.string.story_post_as, + state.chatTitle.ifBlank { stringResource(R.string.story_compose_title) } + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + navigationIcon = { + IconButton(onClick = component::dismiss) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = null) + } + }, + actions = { + IconButton(onClick = component::openMediaPicker) { + Icon(Icons.Rounded.Add, contentDescription = null) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + bottomBar = { + Surface(shadowElevation = 8.dp) { + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + FilledTonalButton( + onClick = component::openMediaPicker, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Rounded.PhotoLibrary, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.story_change_media)) + } + + Button( + onClick = component::submitStory, + enabled = state.composerDraft.isValid && + !state.isSubmitting && + canPublishStory(state.postCapability), + modifier = Modifier.weight(1.2f) + ) { + if (state.isSubmitting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + if (state.isSubmitting) { + stringResource(R.string.story_posting) + } else { + stringResource(R.string.story_publish) + } + ) + } + } + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(scrollState) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + StoryComposerPreviewCard( + state = state, + onSelectMedia = component::openMediaPicker, + onOpenCamera = { + if (hasCameraPermission) { + component.showCamera() + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + ) + + capability?.let { presentation -> + StoryCapabilityCard(presentation = presentation) + } + + state.inlineError?.let { message -> + StoryErrorBanner(message = message) + } + + StorySettingsCard( + title = stringResource(R.string.story_settings_title), + subtitle = stringResource(R.string.story_caption_supporting) + ) { + OutlinedTextField( + value = state.composerDraft.caption, + onValueChange = component::updateCaption, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.story_caption_label)) }, + minLines = 2, + maxLines = 4 + ) + + Spacer(modifier = Modifier.height(12.dp)) + + StoryPrivacySection( + selected = when (state.composerDraft.privacy.mode) { + StoryPrivacyMode.CONTACTS -> StoryPrivacyUi.CONTACTS + StoryPrivacyMode.CLOSE_FRIENDS -> StoryPrivacyUi.CLOSE_FRIENDS + else -> StoryPrivacyUi.EVERYONE + }, + onSelect = component::updatePrivacy + ) + + Spacer(modifier = Modifier.height(12.dp)) + + StoryDurationSection( + selectedSeconds = state.composerDraft.activePeriodSeconds, + onSelect = component::updateActivePeriod + ) + + Spacer(modifier = Modifier.height(12.dp)) + + StorySwitchRow( + title = stringResource(R.string.story_keep_on_profile), + subtitle = stringResource(R.string.story_keep_on_profile_subtitle), + checked = state.composerDraft.keepOnProfile, + onCheckedChange = component::updateKeepOnProfile + ) + Spacer(modifier = Modifier.height(10.dp)) + StorySwitchRow( + title = stringResource(R.string.story_protect_content), + subtitle = stringResource(R.string.story_protect_content_subtitle), + checked = state.composerDraft.protectContent, + onCheckedChange = component::updateProtectContent + ) + } + + if (!state.composerDraft.widgetLink.isNullOrBlank()) { + StorySettingsCard( + title = stringResource(R.string.story_widget_link_title), + subtitle = stringResource(R.string.story_widget_link_subtitle) + ) { + ListItem( + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + leadingContent = { + Icon(Icons.Rounded.Link, contentDescription = null) + }, + headlineContent = { + Text(stringResource(R.string.story_widget_link_title)) + }, + supportingContent = { + Text( + text = state.composerDraft.widgetLink.orEmpty(), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } + } + } + + if (state.showMediaPicker) { + ModalBottomSheet( + onDismissRequest = component::dismissMediaPicker, + sheetState = androidx.compose.material3.rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), + dragHandle = { BottomSheetDefaults.DragHandle() }, + containerColor = MaterialTheme.colorScheme.surface + ) { + GalleryScreen( + onMediaSelected = { uris -> + val first = uris.firstOrNull() ?: return@GalleryScreen + val path = context.copyUriToTempPath(first) ?: return@GalleryScreen + component.attachMedia(path, inferUriMediaType(first)) + }, + onDismiss = component::dismissMediaPicker, + onCameraClick = { + if (hasCameraPermission) { + component.showCamera() + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + }, + canSelectMedia = true, + canUseCamera = true, + canAttachFiles = false, + canCreatePoll = false, + canCreateChecklist = false, + onAttachFileClick = {}, + onCreatePollClick = {}, + onCreateChecklistClick = {}, + attachBots = emptyList(), + hasMediaAccess = hasGalleryAccess || hasFullGalleryPermission() || hasPartialGalleryPermission(), + isPartialAccess = hasPartialGalleryPermission() && !hasFullGalleryPermission(), + onPickFromOtherSources = {}, + onRequestMediaAccess = { + if (requestableGalleryPermissions.isNotEmpty()) { + galleryPermissionLauncher.launch(requestableGalleryPermissions.toTypedArray()) + } + }, + onAttachBotClick = {}, + modifier = Modifier.fillMaxHeight() + ) + } + } + + if (state.showCamera) { + CameraScreen( + onImageCaptured = { uri -> + val path = context.copyUriToTempPath(uri) + if (path != null) { + component.attachMedia(path, StoryMediaType.PHOTO) + } else { + component.dismissCamera() + } + }, + onDismiss = component::dismissCamera + ) + } +} + +@Composable +private fun StoryComposerPreviewCard( + state: StoriesHostComponent.State, + onSelectMedia: () -> Unit, + onOpenCamera: () -> Unit +) { + val context = LocalContext.current + val previewModel = rememberStoryImageModel( + context = context, + primaryPath = state.composerDraft.sourcePath.takeIf { it.isNotBlank() }, + fallbackPath = null, + minithumbnail = null + ) + + Surface( + shape = RoundedCornerShape(32.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 220.dp, max = 340.dp) + .aspectRatio(0.76f) + .clip(RoundedCornerShape(22.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .clickable(onClick = onSelectMedia), + contentAlignment = Alignment.Center + ) { + if (previewModel == null) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(64.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Image, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + Text( + text = stringResource(R.string.story_pick_media), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.story_select_media_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + AsyncImage( + model = previewModel, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.38f) + ) + ) + ) + ) + Row( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + StoryMetadataChip( + icon = if (state.composerDraft.mediaType == StoryMediaType.VIDEO) { + Icons.Rounded.PlayArrow + } else { + Icons.Rounded.Image + }, + label = if (state.composerDraft.mediaType == StoryMediaType.VIDEO) { + stringResource(R.string.media_type_video) + } else { + stringResource(R.string.media_type_photo) + } + ) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + FilledTonalButton( + onClick = onSelectMedia, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Rounded.PhotoLibrary, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.gallery_title_attachments)) + } + OutlinedButton( + onClick = onOpenCamera, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Rounded.CameraAlt, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.permission_camera_title)) + } + } + } + } +} + +@Composable +private fun StorySettingsCard( + title: String, + subtitle: String?, + content: @Composable () -> Unit +) { + Surface( + shape = RoundedCornerShape(30.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + content() + } + } +} + +@Composable +private fun StoryCapabilityCard( + presentation: StoryCapabilityPresentation +) { + Surface( + shape = RoundedCornerShape(28.dp), + color = if (presentation.isBlocking) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.secondaryContainer + } + ) { + Text( + text = presentation.message, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + style = MaterialTheme.typography.bodyMedium, + color = if (presentation.isBlocking) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onSecondaryContainer + } + ) + } +} + +@Composable +private fun StoryStripTile( + title: String, + avatarPath: String?, + hasUnread: Boolean, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .width(80.dp) + .clickable(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Surface( + shape = CircleShape, + color = Color.Transparent, + border = BorderStroke( + width = if (hasUnread) 2.5.dp else 1.dp, + color = if (hasUnread) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.7f) + } + ), + modifier = Modifier.size(70.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(4.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + if (avatarPath != null) { + AsyncImage( + model = avatarPath, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Image, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun StoryPrivacySection( + selected: StoryPrivacyUi, + onSelect: (StoryPrivacyUi) -> Unit +) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + StoryPrivacyUi.entries.forEach { option -> + FilterChip( + selected = selected == option, + onClick = { onSelect(option) }, + label = { + Text( + text = when (option) { + StoryPrivacyUi.EVERYONE -> stringResource(R.string.story_privacy_everyone) + StoryPrivacyUi.CONTACTS -> stringResource(R.string.story_privacy_contacts) + StoryPrivacyUi.CLOSE_FRIENDS -> stringResource(R.string.story_privacy_close_friends) + } + ) + }, + leadingIcon = { + Icon( + imageVector = when (option) { + StoryPrivacyUi.EVERYONE -> Icons.Rounded.Public + StoryPrivacyUi.CONTACTS -> Icons.Rounded.PeopleAlt + StoryPrivacyUi.CLOSE_FRIENDS -> Icons.Rounded.Favorite + }, + contentDescription = null + ) + } + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun StoryDurationSection( + selectedSeconds: Int, + onSelect: (Int) -> Unit +) { + val durations = listOf( + 6 * 60 * 60 to stringResource(R.string.story_duration_6h), + 12 * 60 * 60 to stringResource(R.string.story_duration_12h), + 24 * 60 * 60 to stringResource(R.string.story_duration_24h), + 48 * 60 * 60 to stringResource(R.string.story_duration_48h) + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + durations.forEach { (seconds, label) -> + FilterChip( + selected = selectedSeconds == seconds, + onClick = { onSelect(seconds) }, + label = { Text(text = label) }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Schedule, + contentDescription = null + ) + } + ) + } + } +} + +@Composable +private fun StorySwitchRow( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = if (checked) Icons.Rounded.Shield else Icons.Rounded.Public, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } + } +} + +private fun inferUriMediaType(uri: Uri): StoryMediaType { + val normalized = uri.toString().lowercase() + return if ( + normalized.endsWith(".mp4") || + normalized.endsWith(".mov") || + normalized.endsWith(".webm") || + normalized.endsWith(".mkv") + ) { + StoryMediaType.VIDEO + } else { + StoryMediaType.PHOTO + } +} + +@Composable +private fun StoryInlineVideoPlayer( + path: String, + previewModel: Any?, + isMuted: Boolean, + isPlaying: Boolean, + restartPlaybackToken: Int, + onProgress: (Float) -> Unit, + onBufferingChange: (Boolean) -> Unit, + onPlayingChange: (Boolean) -> Unit, + onCompleted: () -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val currentOnCompleted by rememberUpdatedState(onCompleted) + val mediaUri = remember(path) { resolveVideoUri(path) } + var completed by remember(path) { mutableStateOf(false) } + + val exoPlayer = remember(path) { + ExoPlayer.Builder(context).build().apply { + repeatMode = Player.REPEAT_MODE_OFF + volume = if (isMuted) 0f else 1f + setMediaItem(MediaItem.fromUri(mediaUri)) + prepare() + } + } + + DisposableEffect(exoPlayer, lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { + exoPlayer.pause() + } + } + val listener = object : Player.Listener { + override fun onIsPlayingChanged(isPlayingState: Boolean) { + onPlayingChange(isPlayingState) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + onBufferingChange(playbackState == Player.STATE_BUFFERING) + if (playbackState == Player.STATE_ENDED && !completed) { + completed = true + onProgress(1f) + currentOnCompleted() + } + } + + override fun onPlayerError(error: PlaybackException) { + onBufferingChange(false) + onPlayingChange(false) + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + exoPlayer.addListener(listener) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + exoPlayer.removeListener(listener) + exoPlayer.release() + } + } + + LaunchedEffect(exoPlayer, isMuted) { + exoPlayer.volume = if (isMuted) 0f else 1f + } + + LaunchedEffect(exoPlayer, isPlaying) { + if (isPlaying) { + if (exoPlayer.playbackState == Player.STATE_ENDED) { + completed = false + exoPlayer.seekTo(0) + } + exoPlayer.play() + } else { + exoPlayer.pause() + } + } + + LaunchedEffect(exoPlayer, restartPlaybackToken) { + completed = false + exoPlayer.seekTo(0) + onProgress(0f) + if (isPlaying) { + exoPlayer.play() + } + } + + LaunchedEffect(exoPlayer) { + while (true) { + val duration = exoPlayer.duration + if (duration > 0L) { + onProgress( + (exoPlayer.currentPosition.toFloat() / duration.toFloat()).coerceIn( + 0f, + 1f + ) + ) + } + delay(40) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + if (previewModel != null) { + AsyncImage( + model = previewModel, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + + AndroidView( + factory = { viewContext -> + PlayerView(viewContext).apply { + useController = false + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + player = exoPlayer + } + }, + update = { view -> + view.player = exoPlayer + view.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + }, + modifier = Modifier.fillMaxSize() + ) + } +} + +private fun resolveVideoUri(path: String): Uri { + return when { + path.startsWith("content:") || + path.startsWith("file:") || + path.startsWith("http://") || + path.startsWith("https://") -> Uri.parse(path) + + else -> Uri.fromFile(File(path)) + } +} + +private data class StoryViewerPageState( + val story: StoryModel?, + val viewerIndex: Int, + val isLoading: Boolean, + val inlineError: String? +) + +@Composable +private fun StoryTopIconButton( + onClick: () -> Unit, + contentDescription: String, + content: @Composable () -> Unit +) { + Surface( + onClick = onClick, + shape = CircleShape, + color = Color.Black.copy(alpha = 0.34f) + ) { + Box( + modifier = Modifier + .size(42.dp) + .padding(10.dp), + contentAlignment = Alignment.Center + ) { + content() + } + } +} + +@Composable +private fun StoryViewerSystemBars() { + val view = LocalView.current + DisposableEffect(view) { + val activity = view.context.findActivity() + val window = activity?.window + if (window == null) { + onDispose { } + } else { + val controller = WindowCompat.getInsetsController(window, view) + val previousLightStatusBars = controller.isAppearanceLightStatusBars + val previousStatusBarColor = window.statusBarColor + + controller.isAppearanceLightStatusBars = false + window.statusBarColor = Color.Black.copy(alpha = 0.40f).toArgb() + + onDispose { + controller.isAppearanceLightStatusBars = previousLightStatusBars + window.statusBarColor = previousStatusBarColor + } + } + } +} + +@Composable +private fun StoryHeaderSkeleton() { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + StorySkeletonBar( + modifier = Modifier + .fillMaxWidth(0.42f) + .height(14.dp) + ) + StorySkeletonBar( + modifier = Modifier + .fillMaxWidth(0.68f) + .height(10.dp) + ) + } +} + +@Composable +private fun StorySkeletonBar(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + ) { + StorySkeletonPlaceholder() + } +} + +@Composable +private fun StorySkeletonPlaceholder(modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition(label = "story_skeleton") + val alpha by transition.animateFloat( + initialValue = 0.32f, + targetValue = 0.82f, + animationSpec = infiniteRepeatable( + animation = tween(900), + repeatMode = RepeatMode.Reverse + ), + label = "story_skeleton_alpha" + ) + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.White.copy(alpha = alpha)) + ) +} + +private data class StoryCapabilityPresentation( + val message: String, + val isBlocking: Boolean +) + +internal fun resolveStoryAutoAdvanceDurationMs(story: StoryModel): Int { + val durationSeconds = story.media.durationSeconds + val baseDurationMs = when { + durationSeconds != null && durationSeconds > 0 -> { + (durationSeconds * 1000).roundToInt() + } + + else -> 5_500 + } + val captionBonusMs = if (story.caption.isBlank()) 0 else 1_500 + return (baseDurationMs + captionBonusMs).coerceAtLeast(2_500) +} + +internal fun shouldRestartCurrentStoryFromPreviousTap(progress: Float): Boolean { + return progress > 0.3f +} + +private fun resolveStoryDownloadPath(story: StoryModel?): String? { + return story?.media?.path?.takeIf { it.isNotBlank() } + ?: story?.media?.previewPath?.takeIf { it.isNotBlank() } +} + +private fun buildStoryPositionText(state: StoriesHostComponent.State): String { + val currentChatId = state.chatId + val totalInChat = state.viewerItems.count { it.chatId == currentChatId }.coerceAtLeast(1) + val currentIndexInChat = state.viewerItems + .take(state.viewerIndex + 1) + .count { it.chatId == currentChatId } + .coerceAtLeast(1) + return "$currentIndexInChat/$totalInChat" +} + +private fun formatStoryPostedTime(context: Context, dateSeconds: Int): String { + return DateFormat.getTimeFormat(context).format(Date(dateSeconds * 1000L)) +} + +private tailrec fun Context.findActivity(): Activity? { + return when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null + } +} + +private data class StoryHeaderInfoState( + val title: String, + val storyId: Int?, + val positionText: String, + val postedAt: String +) + +internal fun canPublishStory(capability: StoryPostCapabilityModel?): Boolean { + return capability == null || capability is StoryPostCapabilityModel.Allowed +} + +@Composable +private fun StoryPostCapabilityModel?.toCapabilityPresentation(): StoryCapabilityPresentation? { + return when (this) { + null -> null + is StoryPostCapabilityModel.Allowed -> StoryCapabilityPresentation( + message = stringResource(R.string.story_capability_ready, remainingCount), + isBlocking = false + ) + + StoryPostCapabilityModel.PremiumNeeded -> StoryCapabilityPresentation( + message = stringResource(R.string.story_capability_premium), + isBlocking = true + ) + + StoryPostCapabilityModel.BoostNeeded -> StoryCapabilityPresentation( + message = stringResource(R.string.story_capability_boost), + isBlocking = true + ) + + StoryPostCapabilityModel.ActiveStoryLimitExceeded -> StoryCapabilityPresentation( + message = stringResource(R.string.story_capability_active_limit), + isBlocking = true + ) + + is StoryPostCapabilityModel.WeeklyLimitExceeded -> StoryCapabilityPresentation( + message = stringResource(R.string.story_capability_weekly_limit), + isBlocking = true + ) + + is StoryPostCapabilityModel.MonthlyLimitExceeded -> StoryCapabilityPresentation( + message = stringResource(R.string.story_capability_monthly_limit), + isBlocking = true + ) + + is StoryPostCapabilityModel.LiveStoryActive -> StoryCapabilityPresentation( + message = stringResource(R.string.story_capability_live_active), + isBlocking = true + ) + + is StoryPostCapabilityModel.Unknown -> StoryCapabilityPresentation( + message = message.ifBlank { stringResource(R.string.story_capability_unavailable) }, + isBlocking = true + ) + } +} + +private fun formatDurationLabel(totalSeconds: Int): String { + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return if (minutes > 0) { + "%d:%02d".format(minutes, seconds) + } else { + "0:%02d".format(seconds) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt index 749fde492..e618e743c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt @@ -66,6 +66,7 @@ class MiniAppState( val botPreferences: BotPreferencesProvider, val userRepository: UserRepository, initialThemeParams: ThemeParams, + val onShareToStory: (String, String?, String?) -> Unit, val onDismiss: () -> Unit ) { var themeParams by mutableStateOf(initialThemeParams) @@ -500,7 +501,9 @@ class MiniAppState( } } - override fun onShareToStory(mediaUrl: String, text: String?, widgetLink: JSONObject?) {} + override fun onShareToStory(mediaUrl: String, text: String?, widgetLink: JSONObject?) { + onShareToStory(mediaUrl, text, widgetLink?.toString()) + } override fun onOpenPopup( title: String?, @@ -978,6 +981,7 @@ fun rememberMiniAppState( botPreferences: BotPreferencesProvider, userRepository: UserRepository, themeParams: ThemeParams, + onShareToStory: (String, String?, String?) -> Unit, onDismiss: () -> Unit ) = remember(botUserId) { MiniAppState( @@ -990,6 +994,7 @@ fun rememberMiniAppState( botPreferences, userRepository, themeParams, + onShareToStory, onDismiss ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppViewer.kt index 7bcf868ec..eedd279cf 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppViewer.kt @@ -10,10 +10,24 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBars import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -26,10 +40,28 @@ import kotlinx.coroutines.launch import org.json.JSONObject import org.koin.compose.koinInject import org.monogram.domain.models.webapp.ThemeParams -import org.monogram.domain.repository.* +import org.monogram.domain.repository.BotPreferencesProvider +import org.monogram.domain.repository.FileRepository +import org.monogram.domain.repository.LocationRepository +import org.monogram.domain.repository.PaymentRepository +import org.monogram.domain.repository.UserRepository +import org.monogram.domain.repository.WebAppRepository import org.monogram.presentation.core.util.CryptoManager -import org.monogram.presentation.features.webapp.components.* -import java.util.* +import org.monogram.presentation.features.webapp.components.InvoiceDialog +import org.monogram.presentation.features.webapp.components.MiniAppBottomBar +import org.monogram.presentation.features.webapp.components.MiniAppClosingConfirmationDialog +import org.monogram.presentation.features.webapp.components.MiniAppCustomMethodDialog +import org.monogram.presentation.features.webapp.components.MiniAppFullscreenControls +import org.monogram.presentation.features.webapp.components.MiniAppLoadingView +import org.monogram.presentation.features.webapp.components.MiniAppMenu +import org.monogram.presentation.features.webapp.components.MiniAppPermissionDialog +import org.monogram.presentation.features.webapp.components.MiniAppPermissionsBottomSheet +import org.monogram.presentation.features.webapp.components.MiniAppPopupDialog +import org.monogram.presentation.features.webapp.components.MiniAppQrScanner +import org.monogram.presentation.features.webapp.components.MiniAppTOSBottomSheet +import org.monogram.presentation.features.webapp.components.MiniAppTopBar +import org.monogram.presentation.features.webapp.components.MiniAppWebView +import java.util.Locale import kotlin.math.max private const val TAG = "MiniAppLog" @@ -42,6 +74,7 @@ fun MiniAppViewer( botName: String, botAvatarPath: String? = null, webAppRepository: WebAppRepository, + onShareToStory: (String, String?, String?) -> Unit = { _, _, _ -> }, onDismiss: () -> Unit ) { val context = LocalContext.current @@ -95,6 +128,7 @@ fun MiniAppViewer( botPreferences = botPreferences, userRepository = userRepository, themeParams = themeParams, + onShareToStory = onShareToStory, onDismiss = onDismiss ) diff --git a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt index a92dcf104..de123c195 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt @@ -73,6 +73,8 @@ import org.monogram.presentation.features.profile.contact.DefaultContactEditComp import org.monogram.presentation.features.profile.logs.DefaultProfileLogsComponent import org.monogram.presentation.features.share.IncomingShareRequest import org.monogram.presentation.features.stickers.core.toUi +import org.monogram.presentation.features.stories.DefaultStoriesHostComponent +import org.monogram.presentation.features.stories.StoriesHostComponent import org.monogram.presentation.features.webview.DefaultWebViewComponent import org.monogram.presentation.settings.about.DefaultAboutComponent import org.monogram.presentation.settings.adblock.DefaultAdBlockComponent @@ -116,6 +118,7 @@ class DefaultRootComponent( override val appPreferences: AppPreferences = container.preferences.appPreferences override val videoPlayerPool: VideoPlayerPool = container.utils.videoPlayerPool + override val storiesHost: StoriesHostComponent = DefaultStoriesHostComponent(this) private val navigation = StackNavigation() private val scope = componentScope @@ -260,7 +263,11 @@ class DefaultRootComponent( } override fun onBack() { - navigation.pop() + if (storiesHost.state.value.isVisible) { + storiesHost.dismiss() + } else { + navigation.pop() + } } override fun onSettingsClick() { @@ -294,6 +301,13 @@ class DefaultRootComponent( is LinkAction.OpenChat -> navigateToChat(action.chatId) is LinkAction.OpenUser -> navigation.bringToFront(Config.Profile(action.userId)) is LinkAction.OpenMessage -> navigateToChat(action.chatId, action.messageId) + is LinkAction.OpenStory -> storiesHost.openChatStories(action.chatId, action.storyId) + is LinkAction.OpenStoryAlbum -> storiesHost.openStoryAlbum( + action.chatId, + action.albumId + ) + + is LinkAction.OpenNewStory -> openOwnStoryComposer(preferredMediaType = action.mediaType) is LinkAction.OpenSettings -> { val config = when (action.settingsType) { @@ -481,6 +495,42 @@ class DefaultRootComponent( } } + private fun openStoryComposer( + chatId: Long, + preferredMediaType: org.monogram.domain.models.stories.StoryMediaType? = null, + initialSourcePath: String? = null, + initialCaption: String = "", + widgetLink: String? = null + ) { + scope.launch { + storiesHost.openComposer( + chatId = chatId, + preferredMediaType = preferredMediaType, + initialSourcePath = initialSourcePath, + initialCaption = initialCaption, + widgetLink = widgetLink + ) + } + } + + private fun openOwnStoryComposer( + preferredMediaType: org.monogram.domain.models.stories.StoryMediaType? = null, + initialSourcePath: String? = null, + initialCaption: String = "", + widgetLink: String? = null + ) { + scope.launch { + val me = userRepository.getMe() ?: return@launch + openStoryComposer( + chatId = me.id, + preferredMediaType = preferredMediaType, + initialSourcePath = initialSourcePath, + initialCaption = initialCaption, + widgetLink = widgetLink + ) + } + } + override fun unlock(passcode: String): Boolean { val savedPasscode = appPreferences.passcode.value return if (savedPasscode == passcode) { @@ -588,6 +638,19 @@ class DefaultRootComponent( }, onSettingsClick = { navigation.bringToFront(Config.Settings) }, onProxySettingsClick = { navigation.bringToFront(Config.Proxy) }, + onShareToStory = { mediaUrl, text, widgetLink -> + openOwnStoryComposer( + initialSourcePath = mediaUrl, + initialCaption = text.orEmpty(), + widgetLink = widgetLink + ) + }, + onStorySelect = { chatId, storyId -> + storiesHost.openChatStories(chatId, storyId) + }, + onAddStory = { + openOwnStoryComposer() + }, onConfirmForward = { request -> if (config.forwardingMessageIds != null) { scope.launch { @@ -676,6 +739,13 @@ class DefaultRootComponent( lastConsumedShareRequestId = requestId } }, + onShareToStoryRequested = { mediaUrl, text, widgetLink -> + openOwnStoryComposer( + initialSourcePath = mediaUrl, + initialCaption = text.orEmpty(), + widgetLink = widgetLink + ) + }, toProfiles = { id -> navigation.bringToFront(Config.Profile(chatId = id)) } ) ) @@ -758,7 +828,33 @@ class DefaultRootComponent( onSendMessageClicked = { navigateToChat(it) }, onShowLogsClicked = { navigation.push(Config.ProfileLogs(it)) }, onEditContactClicked = { navigation.push(Config.ContactEdit(it)) }, - onMemberLongClicked = { chatId, userId -> navigation.push(Config.AdminManage(chatId, userId)) } + onMemberLongClicked = { chatId, userId -> + navigation.push( + Config.AdminManage( + chatId, + userId + ) + ) + }, + onOpenStoriesClicked = { targetChatId, storyId -> + storiesHost.openChatStories(targetChatId, storyId) + }, + onOpenStoryArchiveClicked = { targetChatId -> + storiesHost.openChatStories( + targetChatId, + listType = org.monogram.domain.models.stories.StoryListType.ARCHIVE + ) + }, + onCreateStoryClicked = { targetChatId -> + openStoryComposer(targetChatId) + }, + onShareToStoryRequested = { mediaUrl, text, widgetLink -> + openOwnStoryComposer( + initialSourcePath = mediaUrl, + initialCaption = text.orEmpty(), + widgetLink = widgetLink + ) + } ) ) is Config.Premium -> RootComponent.Child.PremiumChild( diff --git a/presentation/src/main/java/org/monogram/presentation/root/RootComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/RootComponent.kt index 5ebd75f2b..b49c42095 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/RootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/RootComponent.kt @@ -23,6 +23,7 @@ import org.monogram.presentation.features.profile.contact.ContactEditComponent import org.monogram.presentation.features.profile.logs.ProfileLogsComponent import org.monogram.presentation.features.share.IncomingShareRequest import org.monogram.presentation.features.stickers.core.StickerSetUiModel +import org.monogram.presentation.features.stories.StoriesHostComponent import org.monogram.presentation.features.webview.WebViewComponent import org.monogram.presentation.settings.about.AboutComponent import org.monogram.presentation.settings.adblock.AdBlockComponent @@ -49,6 +50,7 @@ interface RootComponent { val stickerSetToPreview: StateFlow val proxyToConfirm: StateFlow val chatToConfirmJoin: StateFlow + val storiesHost: StoriesHostComponent val isLocked: StateFlow val isBiometricEnabled: StateFlow val videoPlayerPool: VideoPlayerPool diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 76786e01d..3364237eb 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -565,6 +565,49 @@ Admin Profile stories You can publish stories from your profile + Your story + New story + Pick a photo or video + Caption + Privacy + Everyone + Contacts + Close friends + Visible for + 6h + 12h + 24h + 48h + Keep on profile + Protect content + Publish story + Publishing… + Previous + Next + Story unavailable + Open stories + Create story + Archive + %1$d active stories + %1$d of %2$d + Posting as %1$s + Change media + Choose the photo or video viewers will see first + Optional text shown above your story + Story settings + Pick how long this story stays visible + Show this story on your profile after publishing + Restrict forwarding and saving for viewers + Attached widget + This story includes a mini app link + %1$d story slots available + Premium is required to publish stories from this account. + This chat needs more boosts before it can publish stories. + The active story limit has been reached. + You’ve reached the weekly story limit. + You’ve reached the monthly story limit. + Finish the active live story before publishing a new one. + Story publishing is unavailable right now. Chat background You can set custom backgrounds Voice and video notes diff --git a/presentation/src/test/java/org/monogram/presentation/features/stories/StoriesHostContentTest.kt b/presentation/src/test/java/org/monogram/presentation/features/stories/StoriesHostContentTest.kt new file mode 100644 index 000000000..285e14a34 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/stories/StoriesHostContentTest.kt @@ -0,0 +1,125 @@ +package org.monogram.presentation.features.stories + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.domain.models.stories.ActiveStoryListModel +import org.monogram.domain.models.stories.StoryListType +import org.monogram.domain.models.stories.StoryMediaModel +import org.monogram.domain.models.stories.StoryMediaType +import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryPostCapabilityModel +import org.monogram.domain.models.stories.StoryPrivacyMode +import org.monogram.domain.models.stories.StoryPrivacySettingsModel +import org.monogram.domain.models.stories.StorySummaryModel + +class StoriesHostContentTest { + @Test + fun `resolveStoryAutoAdvanceDurationMs uses default duration for photos without metadata`() { + assertEquals(5_500, resolveStoryAutoAdvanceDurationMs(story())) + } + + @Test + fun `resolveStoryAutoAdvanceDurationMs adds caption bonus`() { + assertEquals(7_000, resolveStoryAutoAdvanceDurationMs(story(caption = "Hello"))) + } + + @Test + fun `resolveStoryAutoAdvanceDurationMs respects explicit media duration`() { + assertEquals( + 3_200, + resolveStoryAutoAdvanceDurationMs( + story(durationSeconds = 3.2) + ) + ) + } + + @Test + fun `canPublishStory allows null and allowed capability`() { + assertTrue(canPublishStory(null)) + assertTrue(canPublishStory(StoryPostCapabilityModel.Allowed(remainingCount = 2))) + } + + @Test + fun `canPublishStory blocks premium and limit capabilities`() { + assertFalse(canPublishStory(StoryPostCapabilityModel.PremiumNeeded)) + assertFalse(canPublishStory(StoryPostCapabilityModel.ActiveStoryLimitExceeded)) + } + + @Test + fun `buildViewerItems flattens stories across chats preserving order`() { + val items = buildViewerItems( + listOf( + activeStories(chatId = 10L, storyIds = listOf(1, 2)), + activeStories(chatId = 20L, storyIds = listOf(3)) + ) + ) + + assertEquals( + listOf( + StoryViewerUiModel(chatId = 10L, storyId = 1, date = 1001), + StoryViewerUiModel(chatId = 10L, storyId = 2, date = 1002), + StoryViewerUiModel(chatId = 20L, storyId = 3, date = 1003) + ), + items + ) + } + + @Test + fun `resolveInitialViewerIndex prefers exact story then first story of chat`() { + val items = listOf( + StoryViewerUiModel(chatId = 10L, storyId = 1, date = 1001), + StoryViewerUiModel(chatId = 10L, storyId = 2, date = 1002), + StoryViewerUiModel(chatId = 20L, storyId = 3, date = 1003) + ) + + assertEquals(1, resolveInitialViewerIndex(items, chatId = 10L, storyId = 2)) + assertEquals(2, resolveInitialViewerIndex(items, chatId = 20L, storyId = 99)) + assertEquals(0, resolveInitialViewerIndex(items, chatId = 30L, storyId = null)) + } + + @Test + fun `shouldRestartCurrentStoryFromPreviousTap restarts only after thirty percent`() { + assertFalse(shouldRestartCurrentStoryFromPreviousTap(0.3f)) + assertTrue(shouldRestartCurrentStoryFromPreviousTap(0.31f)) + } + + private fun story( + caption: String = "", + durationSeconds: Double? = null + ): StoryModel { + return StoryModel( + id = 1, + posterChatId = 1L, + date = 0, + caption = caption, + media = StoryMediaModel( + type = StoryMediaType.PHOTO, + path = "/tmp/story.jpg", + previewPath = null, + durationSeconds = durationSeconds + ), + privacy = StoryPrivacySettingsModel(mode = StoryPrivacyMode.EVERYONE) + ) + } + + private fun activeStories(chatId: Long, storyIds: List): ActiveStoryListModel { + return ActiveStoryListModel( + chatId = chatId, + listType = StoryListType.MAIN, + order = 0, + canBeArchived = false, + maxReadStoryId = 0, + stories = storyIds.map { storyId -> + StorySummaryModel( + storyId = storyId, + date = 1000 + storyId, + isForCloseFriends = false, + isLive = false, + isRead = false + ) + } + ) + } +} From de0dc211807343eeccc115b5f1e02a4d5e23a005 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:19:12 +0300 Subject: [PATCH 02/16] stories: small redesign in chatlist --- .../features/chats/list/ChatListComponent.kt | 4 +- .../features/chats/list/ChatListContent.kt | 386 +++++++++++++----- .../chats/list/DefaultChatListComponent.kt | 53 ++- .../features/stories/StoriesHostContent.kt | 50 +++ 4 files changed, 373 insertions(+), 120 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListComponent.kt index b55368747..1173f0b5a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListComponent.kt @@ -223,7 +223,9 @@ interface ChatListComponent { @Immutable data class StoriesState( val mainActiveStories: List = emptyList(), - val archiveActiveStories: List = emptyList() + val archiveActiveStories: List = emptyList(), + val isMainStoriesLoaded: Boolean = false, + val isArchiveStoriesLoaded: Boolean = false ) sealed class ShareResult { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt index bcf029ef0..e7a6b2616 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt @@ -345,25 +345,62 @@ fun ChatListContent( val density = LocalDensity.current val tabsHeight = if (visibleFolders.size > 1) 56.dp else 10.dp val archiveItemHeight = 78.dp + val storiesHeaderHeight = 124.dp val tabsHeightPx = with(density) { tabsHeight.toPx() } val archiveItemHeightPx = with(density) { archiveItemHeight.toPx() } + val storiesHeaderHeightPx = with(density) { storiesHeaderHeight.toPx() } var headerOffsetPx by remember { mutableFloatStateOf(0f) } var archiveRevealPx by remember { mutableFloatStateOf(0f) } + var storiesOffsetPx by remember { mutableFloatStateOf(0f) } + var tabsOffsetPx by remember { mutableFloatStateOf(0f) } var revealAnimationJob by remember { mutableStateOf(null) } var headerAnimationJob by remember { mutableStateOf(null) } + var storiesAnimationJob by remember { mutableStateOf(null) } + var tabsAnimationJob by remember { mutableStateOf(null) } var hasVibrated by remember { mutableStateOf(false) } var canRevealArchive by remember { mutableStateOf(true) } val currentFolder = visibleFolders.getOrNull(pagerState.currentPage) val isMainFolder = currentFolder?.id == -1 + val isMainView = + !effectiveSearchState.isSearchActive && effectiveFoldersState.selectedFolderId != -2 - val isArchivePersistent = uiState.isArchivePinned && (uiState.isArchiveAlwaysVisible || isMainFolder) - val canShowArchive = isArchivePersistent || isMainFolder + val isArchivePersistent = + uiState.isArchivePinned && (uiState.isArchiveAlwaysVisible || isMainView) + val canShowArchive = isArchivePersistent || isMainView val currentFolderChats = foldersState.chatsByFolder[effectiveFoldersState.selectedFolderId].orEmpty() + val storyIndexChats = remember(foldersState.chatsByFolder) { + LinkedHashMap().apply { + foldersState.chatsByFolder.values.forEach { chats -> + chats.forEach { chat -> putIfAbsent(chat.id, chat) } + } + } + } + val mainStoryStripItems = remember(storyIndexChats, storiesState.mainActiveStories) { + storiesState.mainActiveStories.mapNotNull { storyList -> + val chat = storyIndexChats[storyList.chatId] ?: return@mapNotNull null + StoryStripItemUiModel( + chatId = chat.id, + title = chat.title, + avatarPath = chat.avatarPath, + activeStories = storyList + ) + } + } + val hasMainStoriesStrip = isMainView && mainStoryStripItems.isNotEmpty() + val showCreateStoryStripButton = shouldShowCreateStoryStripButton( + selectedFolderId = effectiveFoldersState.selectedFolderId, + hasVisibleStories = hasMainStoriesStrip + ) + val showCreateStoryFab = shouldShowCreateStoryFab( + selectedFolderId = effectiveFoldersState.selectedFolderId, + areMainStoriesLoaded = storiesState.isMainStoriesLoaded, + hasVisibleStories = hasMainStoriesStrip + ) val hasUnreadInCurrentFolder = remember(currentFolderChats) { currentFolderChats.any(::hasChatListUnreadState) } @@ -408,6 +445,8 @@ fun ChatListContent( } val isArchiveRevealed = archiveRevealPx > 0f && !isArchivePersistent + val hasCollapsibleStories = hasMainStoriesStrip + val hasCollapsibleFolderTabs = visibleFolders.size > 1 if (!isPreview) { BackHandler(enabled = isArchiveRevealed) { revealAnimationJob?.cancel() @@ -419,12 +458,22 @@ fun ChatListContent( } } - val nestedScrollConnection = remember(isArchivePersistent, canShowArchive, uiState.isArchiveAlwaysVisible, tabsHeightPx) { + val nestedScrollConnection = remember( + isArchivePersistent, + canShowArchive, + uiState.isArchiveAlwaysVisible, + storiesHeaderHeightPx, + hasCollapsibleStories, + tabsHeightPx, + hasCollapsibleFolderTabs + ) { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { if (source == NestedScrollSource.UserInput) { revealAnimationJob?.cancel() headerAnimationJob?.cancel() + storiesAnimationJob?.cancel() + tabsAnimationJob?.cancel() } val delta = available.y @@ -440,6 +489,27 @@ fun ChatListContent( return Offset(0f, newReveal - oldReveal) } + if (hasCollapsibleStories && storiesOffsetPx > -storiesHeaderHeightPx) { + val oldStoriesOffset = storiesOffsetPx + val newStoriesOffset = + (oldStoriesOffset + delta).coerceIn(-storiesHeaderHeightPx, 0f) + storiesOffsetPx = newStoriesOffset + + if (abs(newStoriesOffset - oldStoriesOffset) > 0.5f) { + return Offset(0f, newStoriesOffset - oldStoriesOffset) + } + } + + if (hasCollapsibleFolderTabs && tabsOffsetPx > -tabsHeightPx) { + val oldTabsOffset = tabsOffsetPx + val newTabsOffset = (oldTabsOffset + delta).coerceIn(-tabsHeightPx, 0f) + tabsOffsetPx = newTabsOffset + + if (abs(newTabsOffset - oldTabsOffset) > 0.5f) { + return Offset(0f, newTabsOffset - oldTabsOffset) + } + } + val maxHide = if (isArchivePersistent) { -archiveItemHeightPx } else { @@ -458,6 +528,20 @@ fun ChatListContent( return Offset(0f, newOffset - oldOffset) } } else { + if (hasCollapsibleFolderTabs && tabsOffsetPx < 0f) { + val oldTabsOffset = tabsOffsetPx + val newTabsOffset = (oldTabsOffset + delta).coerceAtMost(0f) + tabsOffsetPx = newTabsOffset + return Offset(0f, newTabsOffset - oldTabsOffset) + } + + if (hasCollapsibleStories && storiesOffsetPx < 0f) { + val oldStoriesOffset = storiesOffsetPx + val newStoriesOffset = (oldStoriesOffset + delta).coerceAtMost(0f) + storiesOffsetPx = newStoriesOffset + return Offset(0f, newStoriesOffset - oldStoriesOffset) + } + if (headerOffsetPx < 0f) { if (source == NestedScrollSource.UserInput) { canRevealArchive = false @@ -487,6 +571,8 @@ fun ChatListContent( if (source == NestedScrollSource.UserInput) { revealAnimationJob?.cancel() headerAnimationJob?.cancel() + storiesAnimationJob?.cancel() + tabsAnimationJob?.cancel() } val delta = available.y @@ -495,6 +581,20 @@ fun ChatListContent( canRevealArchive = false } + if (hasCollapsibleFolderTabs && tabsOffsetPx < 0f) { + val oldTabsOffset = tabsOffsetPx + val newTabsOffset = (oldTabsOffset + delta).coerceAtMost(0f) + tabsOffsetPx = newTabsOffset + return Offset(0f, newTabsOffset - oldTabsOffset) + } + + if (hasCollapsibleStories && storiesOffsetPx < 0f) { + val oldStoriesOffset = storiesOffsetPx + val newStoriesOffset = (oldStoriesOffset + delta).coerceAtMost(0f) + storiesOffsetPx = newStoriesOffset + return Offset(0f, newStoriesOffset - oldStoriesOffset) + } + if (headerOffsetPx < 0f) { val oldOffset = headerOffsetPx val newOffset = (oldOffset + delta).coerceAtMost(0f) @@ -522,6 +622,28 @@ fun ChatListContent( override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { canRevealArchive = true + if (hasCollapsibleStories && + storiesOffsetPx < 0f && + storiesOffsetPx > -storiesHeaderHeightPx + ) { + val target = + if (storiesOffsetPx > -storiesHeaderHeightPx / 2) 0f else -storiesHeaderHeightPx + storiesAnimationJob = scope.launch { + animate(initialValue = storiesOffsetPx, targetValue = target) { value, _ -> + storiesOffsetPx = value + } + } + } + + if (hasCollapsibleFolderTabs && tabsOffsetPx < 0f && tabsOffsetPx > -tabsHeightPx) { + val target = if (tabsOffsetPx > -tabsHeightPx / 2) 0f else -tabsHeightPx + tabsAnimationJob = scope.launch { + animate(initialValue = tabsOffsetPx, targetValue = target) { value, _ -> + tabsOffsetPx = value + } + } + } + if (canShowArchive && !isArchivePersistent && archiveRevealPx > 0f && archiveRevealPx < archiveItemHeightPx) { val target = if (archiveRevealPx > archiveItemHeightPx / 2) archiveItemHeightPx else 0f @@ -553,7 +675,13 @@ fun ChatListContent( } } - val isFabExpanded by remember { derivedStateOf { headerOffsetPx > -10f } } + val isFabExpanded by remember { + derivedStateOf { + headerOffsetPx > -10f && + storiesOffsetPx > -10f && + tabsOffsetPx > -10f + } + } var cachedStatusEmojiPath by remember(uiState.currentUser?.id) { mutableStateOf(uiState.currentUser?.statusEmojiPath) @@ -663,7 +791,6 @@ fun ChatListContent( ) } } - Scaffold( containerColor = if (isTablet) Color.Transparent else MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.nestedScroll(nestedScrollConnection), @@ -736,9 +863,6 @@ fun ChatListContent( } } - val isMainView = - !effectiveSearchState.isSearchActive && effectiveFoldersState.selectedFolderId != -2 - if (isMainView) { Box( modifier = Modifier @@ -746,15 +870,18 @@ fun ChatListContent( .height(with(density) { val visibleArchiveHeight = if (isArchivePersistent) { (archiveItemHeightPx + headerOffsetPx).coerceAtLeast(0f) - } else if (isMainFolder) { + } else if (isMainView) { archiveRevealPx } else { 0f } - val visibleTabsHeight = tabsHeightPx - (visibleArchiveHeight + visibleTabsHeight).toDp() - }) - .clip(RoundedCornerShape(bottomStart = 0.dp, bottomEnd = 0.dp)), + val visibleStoriesHeight = if (hasMainStoriesStrip) { + (storiesHeaderHeightPx + storiesOffsetPx).coerceAtLeast(0f) + } else { + 0f + } + (visibleArchiveHeight + visibleStoriesHeight).toDp() + }), contentAlignment = Alignment.TopCenter ) { Column(Modifier.fillMaxWidth()) { @@ -765,7 +892,7 @@ fun ChatListContent( if (isArchivePersistent) { (archiveItemHeightPx + headerOffsetPx).coerceAtLeast(0f) .toDp() - } else if (isMainFolder) { + } else if (isMainView) { archiveRevealPx.toDp() } else { 0.dp @@ -777,7 +904,7 @@ fun ChatListContent( 0f, 1f ) - } else if (isMainFolder) { + } else if (isMainView) { (archiveRevealPx / archiveItemHeightPx).coerceIn(0f, 1f) } else { 0f @@ -807,41 +934,38 @@ fun ChatListContent( } } ) - Spacer(Modifier.height(6.dp)) + Spacer(Modifier.height(10.dp)) } } - if (visibleFolders.size > 1) { - FolderTabs( - modifier = Modifier, - folders = visibleFolders, - pagerState = pagerState, - onTabClick = { index -> - if (isPreview) return@FolderTabs - if (pagerState.currentPage == index) { - val folderId = visibleFolders[index].id - scope.launch { - scrollStates[folderId]?.animateScrollToItem(0) - } - } else { - scope.launch { - pagerState.animateScrollToPage(index) - } + if (hasMainStoriesStrip) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(density) { + (storiesHeaderHeightPx + storiesOffsetPx).coerceAtLeast( + 0f + ) + .toDp() + }) + .graphicsLayer { + alpha = + ((storiesHeaderHeightPx + storiesOffsetPx) / storiesHeaderHeightPx) + .coerceIn(0f, 1f) + clip = true } - }, - onEditClick = { if (!isPreview) component.onEditFoldersClicked() }, - onEditFolderClick = { folder -> - if (!isPreview) component.onEditFolder( - folder.id - ) - }, - onDeleteFolderClick = { folder -> - if (!isPreview) component.onDeleteFolder( - folder.id - ) - }, - onReorderFoldersClick = { if (!isPreview) component.onEditFoldersClicked() } - ) + ) { + StoriesStrip( + items = mainStoryStripItems, + onStoryClick = component::onStoryClicked, + showAddStoryButton = showCreateStoryStripButton && !isPreview, + onAddStoryClick = if (isPreview) null else component::onAddStoryClicked, + modifier = Modifier + .fillMaxWidth() + .height(storiesHeaderHeight) + .offset { IntOffset(0, storiesOffsetPx.roundToInt()) } + ) + } } } } @@ -860,7 +984,7 @@ fun ChatListContent( exit = scaleOut() + fadeOut() ) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - if (effectiveFoldersState.selectedFolderId == -1) { + if (showCreateStoryFab) { ExtendedFloatingActionButton( onClick = component::onAddStoryClicked, icon = { Icon(Icons.Rounded.Add, null) }, @@ -891,34 +1015,90 @@ fun ChatListContent( shape = if (isTablet) RoundedCornerShape(16.dp) else RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), color = if (isTablet) Color.Transparent else MaterialTheme.colorScheme.surface ) { - ChatListBody( - component = component, - foldersState = effectiveFoldersState, - chatsState = chatsState, - selectionState = selectionState, - selectedForwardChatIds = selectedForwardChatIds, - isForwarding = uiState.isForwarding, - isShareTargetMode = uiState.isShareTargetMode, - searchState = effectiveSearchState, - visibleFolders = visibleFolders, - pagerState = pagerState, - scrollStates = scrollStates, - firstFolderTransitionCompleted = firstFolderTransitionCompleted, - currentUserId = uiState.currentUser?.id, - isTablet = isTablet, - emojiFontFamily = emojiFontFamily, - messageLines = messageLines, - showPhotos = showPhotos, - interactionsEnabled = !isPreview, - showProjectChannelPromo = showProjectChannelPromo, - isProjectChannelJoinInProgress = uiState.isProjectChannelJoinInProgress, - onProjectChannelSubscribe = component::onProjectChannelSubscribe, - onProjectChannelLater = component::onProjectChannelLater, - mainActiveStories = storiesState.mainActiveStories, - archiveActiveStories = storiesState.archiveActiveStories, - onChatClicked = onChatClicked, - onChatLongClicked = onChatLongClicked - ) + Column(modifier = Modifier.fillMaxSize()) { + if (isMainView) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(density) { + (tabsHeightPx + tabsOffsetPx).coerceAtLeast(0f).toDp() + }) + .graphicsLayer { + alpha = + ((tabsHeightPx + tabsOffsetPx) / tabsHeightPx).coerceIn(0f, 1f) + clip = true + } + ) { + if (visibleFolders.size > 1) { + FolderTabs( + modifier = Modifier.offset { + IntOffset( + 0, + tabsOffsetPx.roundToInt() + ) + }, + folders = visibleFolders, + pagerState = pagerState, + onTabClick = { index -> + if (isPreview) return@FolderTabs + if (pagerState.currentPage == index) { + val folderId = visibleFolders[index].id + scope.launch { + scrollStates[folderId]?.animateScrollToItem(0) + } + } else { + scope.launch { + pagerState.animateScrollToPage(index) + } + } + }, + onEditClick = { if (!isPreview) component.onEditFoldersClicked() }, + onEditFolderClick = { folder -> + if (!isPreview) component.onEditFolder( + folder.id + ) + }, + onDeleteFolderClick = { folder -> + if (!isPreview) component.onDeleteFolder( + folder.id + ) + }, + onReorderFoldersClick = { if (!isPreview) component.onEditFoldersClicked() } + ) + } + } + } + + Box(modifier = Modifier.weight(1f)) { + ChatListBody( + component = component, + foldersState = effectiveFoldersState, + chatsState = chatsState, + selectionState = selectionState, + selectedForwardChatIds = selectedForwardChatIds, + isForwarding = uiState.isForwarding, + isShareTargetMode = uiState.isShareTargetMode, + searchState = effectiveSearchState, + visibleFolders = visibleFolders, + pagerState = pagerState, + scrollStates = scrollStates, + firstFolderTransitionCompleted = firstFolderTransitionCompleted, + currentUserId = uiState.currentUser?.id, + isTablet = isTablet, + emojiFontFamily = emojiFontFamily, + messageLines = messageLines, + showPhotos = showPhotos, + interactionsEnabled = !isPreview, + showProjectChannelPromo = showProjectChannelPromo, + isProjectChannelJoinInProgress = uiState.isProjectChannelJoinInProgress, + onProjectChannelSubscribe = component::onProjectChannelSubscribe, + onProjectChannelLater = component::onProjectChannelLater, + archiveActiveStories = storiesState.archiveActiveStories, + onChatClicked = onChatClicked, + onChatLongClicked = onChatLongClicked + ) + } + } } } @@ -1373,7 +1553,6 @@ private fun ChatListBody( isProjectChannelJoinInProgress: Boolean, onProjectChannelSubscribe: () -> Unit, onProjectChannelLater: () -> Unit, - mainActiveStories: List, archiveActiveStories: List, onChatClicked: (Long) -> Unit, onChatLongClicked: (Long) -> Unit @@ -1423,7 +1602,6 @@ private fun ChatListBody( isProjectChannelJoinInProgress = isProjectChannelJoinInProgress, onProjectChannelSubscribe = onProjectChannelSubscribe, onProjectChannelLater = onProjectChannelLater, - activeStories = mainActiveStories, onChatClicked = onChatClicked, onChatLongClicked = onChatLongClicked ) @@ -1817,7 +1995,6 @@ private fun FolderPagerContent( isProjectChannelJoinInProgress: Boolean, onProjectChannelSubscribe: () -> Unit, onProjectChannelLater: () -> Unit, - activeStories: List, onChatClicked: (Long) -> Unit, onChatLongClicked: (Long) -> Unit ) { @@ -1858,7 +2035,6 @@ private fun FolderPagerContent( isProjectChannelJoinInProgress = isProjectChannelJoinInProgress, onProjectChannelSubscribe = onProjectChannelSubscribe, onProjectChannelLater = onProjectChannelLater, - activeStories = activeStories, onChatClicked = onChatClicked, onChatLongClicked = onChatLongClicked ) @@ -1891,23 +2067,9 @@ private fun FolderPageContent( isProjectChannelJoinInProgress: Boolean, onProjectChannelSubscribe: () -> Unit, onProjectChannelLater: () -> Unit, - activeStories: List, onChatClicked: (Long) -> Unit, onChatLongClicked: (Long) -> Unit ) { - val storyStripItems = remember(activeStories, folderChats) { - activeStories.mapNotNull { storyList -> - val chat = - folderChats.firstOrNull { it.id == storyList.chatId } ?: return@mapNotNull null - StoryStripItemUiModel( - chatId = chat.id, - title = chat.title, - avatarPath = chat.avatarPath, - activeStories = storyList - ) - } - } - val hasStoriesStrip = shouldShowStoriesStrip(folderId, false) && storyStripItems.isNotEmpty() val shouldHoldInitialFolderContent = folderId > 0 && shouldAnimateFirstFolderTransition && isFolderLoading @@ -1937,16 +2099,12 @@ private fun FolderPageContent( folderId, folderChats.size, isFolderLoading, - hasStoriesStrip, scrollState, interactionsEnabled ) { if (!interactionsEnabled || isFolderLoading || folderChats.isEmpty()) return@LaunchedEffect - val headerItemsCount = listOf( - showProjectChannelPromo, - hasStoriesStrip - ).count { it } + val headerItemsCount = if (showProjectChannelPromo) 1 else 0 snapshotFlow { val lastVisible = (scrollState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1) - headerItemsCount @@ -1964,16 +2122,12 @@ private fun FolderPageContent( folderId, folderChats, isFolderLoading, - hasStoriesStrip, scrollState, interactionsEnabled ) { if (!interactionsEnabled || isFolderLoading || folderChats.isEmpty()) return@LaunchedEffect - val headerItemsCount = listOf( - showProjectChannelPromo, - hasStoriesStrip - ).count { it } + val headerItemsCount = if (showProjectChannelPromo) 1 else 0 snapshotFlow { scrollState.layoutInfo.visibleItemsInfo .mapNotNull { item -> folderChats.getOrNull(item.index - headerItemsCount)?.id } @@ -2018,21 +2172,12 @@ private fun FolderPageContent( .fillMaxSize() .semantics { contentDescription = "ChatList" }, contentPadding = PaddingValues( - top = 12.dp, + top = 8.dp, bottom = 88.dp, start = if (isTablet) 4.dp else 0.dp, end = if (isTablet) 4.dp else 0.dp ) ) { - if (hasStoriesStrip) { - item { - StoriesStrip( - items = storyStripItems, - onStoryClick = component::onStoryClicked - ) - } - } - if (showProjectChannelPromo) { item { ProjectChannelPromoCard( @@ -2854,3 +2999,18 @@ internal fun shouldShowProjectChannelPromo( !hasSelection && subscriptionState == ChatListComponent.ProjectChannelSubscriptionState.NOT_SUBSCRIBED } + +internal fun shouldShowCreateStoryFab( + selectedFolderId: Int, + areMainStoriesLoaded: Boolean, + hasVisibleStories: Boolean +): Boolean { + return selectedFolderId != -2 && areMainStoriesLoaded && !hasVisibleStories +} + +internal fun shouldShowCreateStoryStripButton( + selectedFolderId: Int, + hasVisibleStories: Boolean +): Boolean { + return selectedFolderId != -2 && hasVisibleStories +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt index e4e2ca301..f7aebde2d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt @@ -26,7 +26,10 @@ import org.monogram.domain.models.ChatModel import org.monogram.domain.models.ChatType import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.UpdateState +import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.repository.AttachMenuBotRepository +import org.monogram.domain.repository.AuthRepository +import org.monogram.domain.repository.AuthStep import org.monogram.domain.repository.BotRepository import org.monogram.domain.repository.ChatFolderRepository import org.monogram.domain.repository.ChatListRepository @@ -76,6 +79,7 @@ class DefaultChatListComponent( ) : ChatListComponent, AppComponentContext by context { private val isTelemtBuild = BuildConfig.ENABLE_TELEMT_DNS + private val authRepository: AuthRepository = container.repositories.authRepository private val chatListRepository: ChatListRepository = container.repositories.chatListRepository private val chatFolderRepository: ChatFolderRepository = container.repositories.chatFolderRepository private val chatSearchRepository: ChatSearchRepository = container.repositories.chatSearchRepository @@ -128,6 +132,7 @@ class DefaultChatListComponent( private var searchJob: Job? = null private var isFetchingMoreMessages = false private var nextMessagesOffset = "" + private var hasRequestedStoryLists = false private var hasSeenResume = false private val prefetchSemaphore = Semaphore(PREFETCH_CONCURRENCY) private val messagePrefetchTimestamps = mutableMapOf() @@ -434,13 +439,43 @@ class DefaultChatListComponent( .onEach { activeStories -> Log.d( STORY_TAG, - "story repository update main=${activeStories[org.monogram.domain.models.stories.StoryListType.MAIN].orEmpty().size} " + - "archive=${activeStories[org.monogram.domain.models.stories.StoryListType.ARCHIVE].orEmpty().size}" + "story repository update main=${activeStories[StoryListType.MAIN].orEmpty().size} " + + "archive=${activeStories[StoryListType.ARCHIVE].orEmpty().size}" ) refreshStoriesState("repository") } .launchIn(scope) + storyRepository.storyListChatCounts + .onEach { counts -> + Log.d( + STORY_TAG, + "story list counts update main=${counts[StoryListType.MAIN]} archive=${counts[StoryListType.ARCHIVE]}" + ) + refreshStoriesState("counts") + } + .launchIn(scope) + + authRepository.authState + .onEach { authState -> + when (authState) { + is AuthStep.Ready -> { + if (!hasRequestedStoryLists) { + hasRequestedStoryLists = true + scope.launch(Dispatchers.IO) { + storyRepository.loadActiveStories(StoryListType.MAIN) + storyRepository.loadActiveStories(StoryListType.ARCHIVE) + } + } + } + + else -> { + hasRequestedStoryLists = false + } + } + } + .launchIn(scope) + scope.launch { if (!isTelemtBuild) { updateRepository.checkForUpdates() @@ -1277,8 +1312,8 @@ class DefaultChatListComponent( private fun refreshStoriesState(reason: String) { scope.launch(Dispatchers.IO) { val repositoryStories = storyRepository.activeStories.value - val activeStories = - repositoryStories[org.monogram.domain.models.stories.StoryListType.MAIN].orEmpty() + val storyListChatCounts = storyRepository.storyListChatCounts.value + val activeStories = repositoryStories[StoryListType.MAIN].orEmpty() val archiveFolderChatIds = _state.value.chatsByFolder[ARCHIVE_FOLDER_ID] .orEmpty() .mapTo(mutableSetOf()) { it.id } @@ -1313,15 +1348,21 @@ class DefaultChatListComponent( val archiveStories = resolvedStories .filter { (_, _, isArchived) -> isArchived } .map { (_, stories, _) -> stories } + val isMainStoriesLoaded = storyListChatCounts.containsKey(StoryListType.MAIN) || + repositoryStories.containsKey(StoryListType.MAIN) + val isArchiveStoriesLoaded = storyListChatCounts.containsKey(StoryListType.ARCHIVE) || + repositoryStories.containsKey(StoryListType.ARCHIVE) Log.d( STORY_TAG, - "refreshStoriesState reason=$reason total=${activeStories.size} main=${mainStories.size} archived=${archiveStories.size} archiveFolderIds=${archiveFolderChatIds.size}" + "refreshStoriesState reason=$reason total=${activeStories.size} main=${mainStories.size} archived=${archiveStories.size} archiveFolderIds=${archiveFolderChatIds.size} mainLoaded=$isMainStoriesLoaded archiveLoaded=$isArchiveStoriesLoaded" ) _storiesState.value = ChatListComponent.StoriesState( mainActiveStories = mainStories, - archiveActiveStories = archiveStories + archiveActiveStories = archiveStories, + isMainStoriesLoaded = isMainStoriesLoaded, + isArchiveStoriesLoaded = isArchiveStoriesLoaded ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt index 6fc12a4f4..360b106a2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt @@ -120,6 +120,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -175,6 +176,8 @@ fun StoriesHostContent(component: StoriesHostComponent) { fun StoriesStrip( items: List, onStoryClick: (Long, Int?) -> Unit, + showAddStoryButton: Boolean = false, + onAddStoryClick: (() -> Unit)? = null, modifier: Modifier = Modifier ) { if (items.isEmpty()) return @@ -184,6 +187,11 @@ fun StoriesStrip( contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { + if (showAddStoryButton && onAddStoryClick != null) { + item(key = "story_add") { + AddStoryStripTile(onClick = onAddStoryClick) + } + } items(items, key = { it.chatId }) { item -> StoryStripTile( title = item.title, @@ -1594,7 +1602,49 @@ private fun StoryStripTile( } Text( text = title, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun AddStoryStripTile( + onClick: () -> Unit +) { + Column( + modifier = Modifier + .width(80.dp) + .clickable(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + border = BorderStroke(1.5.dp, MaterialTheme.colorScheme.secondary), + modifier = Modifier.size(70.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(28.dp) + ) + } + } + Text( + text = stringResource(R.string.story_create), + modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis ) From f240f701df38271b7c2669ceeeddebf636170364 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:10:50 +0300 Subject: [PATCH 03/16] stories: stories creation, shrink code --- .../data/mapper/StoryInteractionMapper.kt | 83 + .../org/monogram/data/mapper/StoryMapper.kt | 70 + .../data/repository/StoryRepositoryImpl.kt | 69 + .../domain/models/stories/StoryModels.kt | 110 +- .../repository/AppPreferencesProvider.kt | 2 + .../domain/repository/StoryRepository.kt | 24 + .../presentation/core/util/AppPreferences.kt | 11 + .../profile/components/StatisticsViewer.kt | 20 +- .../stories/DefaultStoriesHostComponent.kt | 688 +++++- .../features/stories/StoriesHostComponent.kt | 35 +- .../features/stories/StoriesHostContent.kt | 1901 +++-------------- .../components/StoriesStripComponents.kt | 139 ++ .../StoryComposerOverlayComponents.kt | 1625 ++++++++++++++ .../StoryComposerSettingsComponents.kt | 256 +++ .../components/StoryViewerComponents.kt | 695 ++++++ .../components/StoryViewerSceneComponents.kt | 1132 ++++++++++ presentation/src/main/res/values/string.xml | 48 +- 17 files changed, 5284 insertions(+), 1624 deletions(-) create mode 100644 data/src/main/java/org/monogram/data/mapper/StoryInteractionMapper.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/stories/components/StoriesStripComponents.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryComposerOverlayComponents.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryComposerSettingsComponents.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt diff --git a/data/src/main/java/org/monogram/data/mapper/StoryInteractionMapper.kt b/data/src/main/java/org/monogram/data/mapper/StoryInteractionMapper.kt new file mode 100644 index 000000000..efb74e536 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/StoryInteractionMapper.kt @@ -0,0 +1,83 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.data.mapper.user.toDomain +import org.monogram.domain.models.stories.StoryInteractionActorType +import org.monogram.domain.models.stories.StoryInteractionModel +import org.monogram.domain.models.stories.StoryInteractionPageModel +import org.monogram.domain.models.stories.StoryInteractionTypeModel +import org.monogram.domain.models.stories.StoryStatisticsModel + +object StoryInteractionMapper { + fun mapStoryStatistics(statistics: TdApi.StoryStatistics): StoryStatisticsModel { + return StoryStatisticsModel( + storyInteractionGraph = statistics.storyInteractionGraph.toDomain(), + storyReactionGraph = statistics.storyReactionGraph.toDomain() + ) + } + + fun mapStoryInteractions(interactions: TdApi.StoryInteractions): StoryInteractionPageModel { + return StoryInteractionPageModel( + totalCount = interactions.totalCount, + totalForwardCount = interactions.totalForwardCount, + totalReactionCount = interactions.totalReactionCount, + interactions = interactions.interactions.orEmpty().map(::mapStoryInteraction), + nextOffset = interactions.nextOffset.orEmpty() + ) + } + + private fun mapStoryInteraction(interaction: TdApi.StoryInteraction): StoryInteractionModel { + val actorId = when (val actor = interaction.actorId) { + is TdApi.MessageSenderUser -> actor.userId + is TdApi.MessageSenderChat -> actor.chatId + else -> 0L + } + val actorType = when (interaction.actorId) { + is TdApi.MessageSenderChat -> StoryInteractionActorType.CHAT + else -> StoryInteractionActorType.USER + } + + return when (val type = interaction.type) { + is TdApi.StoryInteractionTypeView -> StoryInteractionModel( + actorId = actorId, + actorType = actorType, + interactionDate = interaction.interactionDate, + type = StoryInteractionTypeModel.VIEW, + reaction = type.chosenReactionType.toReactionLabel() + ) + + is TdApi.StoryInteractionTypeForward -> StoryInteractionModel( + actorId = actorId, + actorType = actorType, + interactionDate = interaction.interactionDate, + type = StoryInteractionTypeModel.FORWARD, + forwardChatId = type.message.chatId.takeIf { it != 0L }, + forwardMessageId = type.message.id.takeIf { it != 0L } + ) + + is TdApi.StoryInteractionTypeRepost -> StoryInteractionModel( + actorId = actorId, + actorType = actorType, + interactionDate = interaction.interactionDate, + type = StoryInteractionTypeModel.REPOST, + repostStoryId = type.story.id.takeIf { it != 0 } + ) + + else -> StoryInteractionModel( + actorId = actorId, + actorType = actorType, + interactionDate = interaction.interactionDate, + type = StoryInteractionTypeModel.VIEW + ) + } + } + + private fun TdApi.ReactionType?.toReactionLabel(): String? { + return when (this) { + is TdApi.ReactionTypeEmoji -> emoji + is TdApi.ReactionTypeCustomEmoji -> "custom:$customEmojiId" + is TdApi.ReactionTypePaid -> "paid" + else -> null + } + } +} diff --git a/data/src/main/java/org/monogram/data/mapper/StoryMapper.kt b/data/src/main/java/org/monogram/data/mapper/StoryMapper.kt index 81e2ceff0..e48d52b94 100644 --- a/data/src/main/java/org/monogram/data/mapper/StoryMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/StoryMapper.kt @@ -2,6 +2,9 @@ package org.monogram.data.mapper import org.drinkless.tdlib.TdApi import org.monogram.domain.models.stories.ActiveStoryListModel +import org.monogram.domain.models.stories.StoryAreaModel +import org.monogram.domain.models.stories.StoryAreaPositionModel +import org.monogram.domain.models.stories.StoryAreaTypeModel import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.models.stories.StoryMediaModel import org.monogram.domain.models.stories.StoryMediaType @@ -9,6 +12,7 @@ import org.monogram.domain.models.stories.StoryModel import org.monogram.domain.models.stories.StoryPostCapabilityModel import org.monogram.domain.models.stories.StoryPrivacyMode import org.monogram.domain.models.stories.StoryPrivacySettingsModel +import org.monogram.domain.models.stories.StoryReactionModel import org.monogram.domain.models.stories.StoryStealthModeModel import org.monogram.domain.models.stories.StorySummaryModel @@ -47,6 +51,7 @@ object StoryMapper { media = mediaOverride ?: story.content.toDomainMedia(), privacy = story.privacySettings.toDomainPrivacy(), albumIds = story.albumIds?.toList().orEmpty(), + areas = story.areas.orEmpty().mapNotNull(::mapStoryArea), linkUrls = story.areas.orEmpty() .mapNotNull { area -> (area.type as? TdApi.StoryAreaTypeLink)?.url }, isBeingPosted = story.isBeingPosted, @@ -148,6 +153,71 @@ object StoryMapper { } } + private fun mapStoryArea(area: TdApi.StoryArea?): StoryAreaModel? { + area ?: return null + val position = area.position ?: return null + val type = mapStoryAreaType(area.type) ?: return null + return StoryAreaModel( + position = StoryAreaPositionModel( + xPercentage = position.xPercentage, + yPercentage = position.yPercentage, + widthPercentage = position.widthPercentage, + heightPercentage = position.heightPercentage, + rotationAngle = position.rotationAngle, + cornerRadiusPercentage = position.cornerRadiusPercentage + ), + type = type + ) + } + + private fun mapStoryAreaType(type: TdApi.StoryAreaType?): StoryAreaTypeModel? { + return when (type) { + is TdApi.StoryAreaTypeLocation -> StoryAreaTypeModel.Location( + label = listOfNotNull( + type.address?.street?.takeIf { it.isNotBlank() }, + type.address?.city?.takeIf { it.isNotBlank() }, + type.address?.state?.takeIf { it.isNotBlank() } + ).firstOrNull().orEmpty().ifBlank { "Location" } + ) + + is TdApi.StoryAreaTypeVenue -> StoryAreaTypeModel.Venue( + title = type.venue?.title.orEmpty().ifBlank { "Venue" }, + address = type.venue?.address?.takeIf { it.isNotBlank() } + ) + + is TdApi.StoryAreaTypeSuggestedReaction -> StoryAreaTypeModel.SuggestedReaction( + reaction = type.reactionType.toDomainReaction(), + totalCount = type.totalCount, + isDark = type.isDark, + isFlipped = type.isFlipped + ) + + is TdApi.StoryAreaTypeMessage -> StoryAreaTypeModel.Message( + chatId = type.chatId, + messageId = type.messageId + ) + + is TdApi.StoryAreaTypeLink -> StoryAreaTypeModel.Link(type.url) + is TdApi.StoryAreaTypeWeather -> StoryAreaTypeModel.Weather( + temperature = type.temperature, + emoji = type.emoji, + backgroundColorArgb = type.backgroundColor + ) + + is TdApi.StoryAreaTypeUpgradedGift -> StoryAreaTypeModel.UpgradedGift(type.giftName) + else -> null + } + } + + private fun TdApi.ReactionType?.toDomainReaction(): StoryReactionModel { + return when (this) { + is TdApi.ReactionTypeEmoji -> StoryReactionModel(emoji = emoji) + is TdApi.ReactionTypeCustomEmoji -> StoryReactionModel(customEmojiId = customEmojiId) + is TdApi.ReactionTypePaid -> StoryReactionModel(isPaid = true) + else -> StoryReactionModel() + } + } + private fun TdApi.StoryContent.toDomainMedia(): StoryMediaModel { return when (this) { is TdApi.StoryContentPhoto -> { diff --git a/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt index bc2a45d77..321fdfdc0 100644 --- a/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt @@ -12,18 +12,22 @@ import org.json.JSONObject import org.monogram.data.datasource.FileDataSource import org.monogram.data.gateway.TelegramGateway import org.monogram.data.gateway.UpdateDispatcher +import org.monogram.data.mapper.StoryInteractionMapper import org.monogram.data.mapper.StoryMapper import org.monogram.data.mapper.StoryMapper.toDomainStoryListType import org.monogram.data.mapper.StoryMapper.toTdPrivacy import org.monogram.data.mapper.StoryMapper.toTdStoryList import org.monogram.domain.models.stories.ActiveStoryListModel import org.monogram.domain.models.stories.StoryComposerDraftModel +import org.monogram.domain.models.stories.StoryInteractionPageModel import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.models.stories.StoryMediaModel import org.monogram.domain.models.stories.StoryMediaType import org.monogram.domain.models.stories.StoryModel import org.monogram.domain.models.stories.StoryPostCapabilityModel import org.monogram.domain.models.stories.StoryPostResultModel +import org.monogram.domain.models.stories.StoryReactionModel +import org.monogram.domain.models.stories.StoryStatisticsModel import org.monogram.domain.models.stories.StoryStealthModeModel import org.monogram.domain.repository.StoryRepository @@ -153,6 +157,62 @@ class StoryRepositoryImpl( }.getOrElse { StoryPostCapabilityModel.Unknown(it.message ?: "Unable to check capability") } } + override suspend fun getStoryStatistics( + chatId: Long, + storyId: Int, + isDark: Boolean + ): StoryStatisticsModel? { + return runCatching { + StoryInteractionMapper.mapStoryStatistics( + gateway.execute(TdApi.GetStoryStatistics(chatId, storyId, isDark)) + ) + }.getOrNull() + } + + override suspend fun setStoryReaction( + chatId: Long, + storyId: Int, + reaction: StoryReactionModel + ): Boolean { + return runCatching { + gateway.execute( + TdApi.SetStoryReaction( + chatId, + storyId, + reaction.toTdReactionType(), + true + ) + ) + true + }.getOrDefault(false) + } + + override suspend fun getStoryInteractions( + storyId: Int, + offset: String, + limit: Int, + query: String, + onlyContacts: Boolean, + preferForwards: Boolean, + preferWithReaction: Boolean + ): StoryInteractionPageModel? { + return runCatching { + StoryInteractionMapper.mapStoryInteractions( + gateway.execute( + TdApi.GetStoryInteractions( + storyId, + query, + onlyContacts, + preferForwards, + preferWithReaction, + offset, + limit + ) + ) + ) + }.getOrNull() + } + override suspend fun postStory( chatId: Long, draft: StoryComposerDraftModel @@ -387,6 +447,15 @@ class StoryRepositoryImpl( } } + private fun StoryReactionModel.toTdReactionType(): TdApi.ReactionType { + return when { + emoji != null -> TdApi.ReactionTypeEmoji(emoji) + customEmojiId != null -> TdApi.ReactionTypeCustomEmoji(customEmojiId ?: 0L) + isPaid -> TdApi.ReactionTypePaid() + else -> TdApi.ReactionTypeEmoji("❤") + } + } + private suspend fun resolveStoryMedia(content: TdApi.StoryContent): StoryMediaModel { return when (content) { is TdApi.StoryContentPhoto -> { diff --git a/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt b/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt index cdc3adb66..325ed61f1 100644 --- a/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt +++ b/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt @@ -1,5 +1,7 @@ package org.monogram.domain.models.stories +import org.monogram.domain.models.StatisticsGraphModel + data class ActiveStoryListModel( val chatId: Long, val listType: StoryListType, @@ -25,6 +27,7 @@ data class StoryModel( val media: StoryMediaModel, val privacy: StoryPrivacySettingsModel, val albumIds: List = emptyList(), + val areas: List = emptyList(), val linkUrls: List = emptyList(), val isBeingPosted: Boolean = false, val isBeingEdited: Boolean = false, @@ -43,6 +46,50 @@ data class StoryModel( val isRead: Boolean = false ) +data class StoryAreaModel( + val position: StoryAreaPositionModel, + val type: StoryAreaTypeModel +) + +data class StoryAreaPositionModel( + val xPercentage: Double, + val yPercentage: Double, + val widthPercentage: Double, + val heightPercentage: Double, + val rotationAngle: Double, + val cornerRadiusPercentage: Double +) + +sealed class StoryAreaTypeModel { + data class Location(val label: String) : StoryAreaTypeModel() + data class Venue(val title: String, val address: String? = null) : StoryAreaTypeModel() + data class SuggestedReaction( + val reaction: StoryReactionModel, + val totalCount: Int, + val isDark: Boolean, + val isFlipped: Boolean + ) : StoryAreaTypeModel() + + data class Message(val chatId: Long, val messageId: Long) : StoryAreaTypeModel() + data class Link(val url: String) : StoryAreaTypeModel() + data class Weather( + val temperature: Double, + val emoji: String, + val backgroundColorArgb: Int + ) : StoryAreaTypeModel() + + data class UpgradedGift(val giftName: String) : StoryAreaTypeModel() +} + +data class StoryReactionModel( + val emoji: String? = null, + val customEmojiId: Long? = null, + val isPaid: Boolean = false +) { + val isCustomEmoji: Boolean + get() = customEmojiId != null +} + data class StoryMediaModel( val type: StoryMediaType, val path: String?, @@ -57,9 +104,14 @@ data class StoryViewerItemModel( val storyId: Int ) +data class StoryComposerMediaItemModel( + val sourcePath: String, + val mediaType: StoryMediaType +) + data class StoryComposerDraftModel( - val sourcePath: String = "", - val mediaType: StoryMediaType = StoryMediaType.PHOTO, + val mediaItems: List = emptyList(), + val selectedMediaIndex: Int = 0, val caption: String = "", val privacy: StoryPrivacySettingsModel = StoryPrivacySettingsModel(mode = StoryPrivacyMode.EVERYONE), val activePeriodSeconds: Int = DEFAULT_ACTIVE_PERIOD_SECONDS, @@ -68,7 +120,19 @@ data class StoryComposerDraftModel( val widgetLink: String? = null ) { val isValid: Boolean - get() = sourcePath.isNotBlank() + get() = mediaItems.isNotEmpty() + + val currentMedia: StoryComposerMediaItemModel? + get() = mediaItems.getOrNull(selectedMediaIndex) ?: mediaItems.firstOrNull() + + val sourcePath: String + get() = currentMedia?.sourcePath.orEmpty() + + val mediaType: StoryMediaType + get() = currentMedia?.mediaType ?: StoryMediaType.PHOTO + + val mediaCount: Int + get() = mediaItems.size companion object { const val DEFAULT_ACTIVE_PERIOD_SECONDS = 24 * 60 * 60 @@ -89,6 +153,46 @@ data class StoryStealthModeModel( get() = activeUntilDate > 0 } +data class StoryStatisticsModel( + val storyInteractionGraph: StatisticsGraphModel, + val storyReactionGraph: StatisticsGraphModel +) + +data class StoryInteractionModel( + val actorId: Long, + val actorType: StoryInteractionActorType, + val interactionDate: Int, + val type: StoryInteractionTypeModel, + val reaction: String? = null, + val forwardChatId: Long? = null, + val forwardMessageId: Long? = null, + val repostStoryId: Int? = null, + val actorTitle: String? = null, + val actorAvatarPath: String? = null +) + +data class StoryInteractionPageModel( + val totalCount: Int, + val totalForwardCount: Int, + val totalReactionCount: Int, + val interactions: List, + val nextOffset: String = "" +) { + val canLoadMore: Boolean + get() = nextOffset.isNotBlank() +} + +enum class StoryInteractionActorType { + USER, + CHAT +} + +enum class StoryInteractionTypeModel { + VIEW, + FORWARD, + REPOST +} + sealed class StoryPostCapabilityModel { data class Allowed(val remainingCount: Int) : StoryPostCapabilityModel() data object PremiumNeeded : StoryPostCapabilityModel() diff --git a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt index d33ebbf1d..05d3e0516 100644 --- a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt +++ b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt @@ -73,6 +73,7 @@ interface AppPreferencesProvider { val showChatListPhotos: StateFlow val showReactions: StateFlow val showSponsoredMessagesForPremium: StateFlow + val storyMediaStretchEnabled: StateFlow val privateChatsNotifications: StateFlow val groupsNotifications: StateFlow @@ -130,6 +131,7 @@ interface AppPreferencesProvider { fun setShowChatListPhotos(enabled: Boolean) fun setShowReactions(enabled: Boolean) fun setShowSponsoredMessagesForPremium(enabled: Boolean) + fun setStoryMediaStretchEnabled(enabled: Boolean) fun setPrivateChatsNotifications(enabled: Boolean) fun setGroupsNotifications(enabled: Boolean) diff --git a/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt b/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt index e5ca665b4..e74cbbe87 100644 --- a/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt @@ -3,10 +3,13 @@ package org.monogram.domain.repository import kotlinx.coroutines.flow.StateFlow import org.monogram.domain.models.stories.ActiveStoryListModel import org.monogram.domain.models.stories.StoryComposerDraftModel +import org.monogram.domain.models.stories.StoryInteractionPageModel import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.models.stories.StoryModel import org.monogram.domain.models.stories.StoryPostCapabilityModel import org.monogram.domain.models.stories.StoryPostResultModel +import org.monogram.domain.models.stories.StoryReactionModel +import org.monogram.domain.models.stories.StoryStatisticsModel import org.monogram.domain.models.stories.StoryStealthModeModel interface StoryRepository { @@ -28,6 +31,27 @@ interface StoryRepository { suspend fun openStory(chatId: Long, storyId: Int) suspend fun closeStory(chatId: Long, storyId: Int) suspend fun canPostStory(chatId: Long): StoryPostCapabilityModel + suspend fun getStoryStatistics( + chatId: Long, + storyId: Int, + isDark: Boolean + ): StoryStatisticsModel? + + suspend fun setStoryReaction( + chatId: Long, + storyId: Int, + reaction: StoryReactionModel + ): Boolean + + suspend fun getStoryInteractions( + storyId: Int, + offset: String, + limit: Int, + query: String = "", + onlyContacts: Boolean = false, + preferForwards: Boolean = false, + preferWithReaction: Boolean = false + ): StoryInteractionPageModel? suspend fun postStory(chatId: Long, draft: StoryComposerDraftModel): StoryPostResultModel suspend fun editStory(chatId: Long, storyId: Int, draft: StoryComposerDraftModel): Boolean suspend fun deleteStory(chatId: Long, storyId: Int): Boolean diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt index a2bc3f858..f47427d7c 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt @@ -339,6 +339,10 @@ class AppPreferences( override val showSponsoredMessagesForPremium: StateFlow = _showSponsoredMessagesForPremium + private val _storyMediaStretchEnabled = + MutableStateFlow(prefs.getBoolean(KEY_STORY_MEDIA_STRETCH_ENABLED, true)) + override val storyMediaStretchEnabled: StateFlow = _storyMediaStretchEnabled + private val _showAllChatsFolder = MutableStateFlow(prefs.getBoolean(KEY_SHOW_ALL_CHATS_FOLDER, true)) val showAllChatsFolder: StateFlow = _showAllChatsFolder @@ -1008,6 +1012,11 @@ class AppPreferences( _showSponsoredMessagesForPremium.value = enabled } + override fun setStoryMediaStretchEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_STORY_MEDIA_STRETCH_ENABLED, enabled).apply() + _storyMediaStretchEnabled.value = enabled + } + fun setShowAllChatsFolder(enabled: Boolean) { prefs.edit().putBoolean(KEY_SHOW_ALL_CHATS_FOLDER, enabled).apply() _showAllChatsFolder.value = enabled @@ -1214,6 +1223,7 @@ class AppPreferences( _chatListMessageLines.value = 1 _showChatListPhotos.value = true _showSponsoredMessagesForPremium.value = false + _storyMediaStretchEnabled.value = true _showAllChatsFolder.value = true _isTabletInterfaceEnabled.value = true _isAdBlockEnabled.value = false @@ -1370,6 +1380,7 @@ class AppPreferences( private const val KEY_SHOW_REACTIONS = "show_reactions" private const val KEY_SHOW_SPONSORED_MESSAGES_FOR_PREMIUM = "show_sponsored_messages_for_premium" + private const val KEY_STORY_MEDIA_STRETCH_ENABLED = "story_media_stretch_enabled" private const val KEY_SHOW_ALL_CHATS_FOLDER = "show_all_chats_folder" private const val KEY_TABLET_INTERFACE_ENABLED = "tablet_interface_enabled" diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/StatisticsViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/StatisticsViewer.kt index e878146c8..a998bdaec 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/StatisticsViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/StatisticsViewer.kt @@ -112,10 +112,11 @@ import org.monogram.domain.models.ChatStatisticsModel import org.monogram.domain.models.DateRangeModel import org.monogram.domain.models.StatisticsGraphModel import org.monogram.domain.models.StatisticsType +import org.monogram.domain.models.stories.StoryStatisticsModel import org.monogram.presentation.R +import org.monogram.presentation.core.ui.SectionHeader import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.coRunCatching -import org.monogram.presentation.core.ui.SectionHeader import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -198,6 +199,7 @@ fun StatisticsViewer( } is ChatRevenueStatisticsModel -> RevenueStatistics(stateData, onLoadGraph) + is StoryStatisticsModel -> StoryStatistics(stateData, onLoadGraph) else -> FallbackView(stateData) } } @@ -419,6 +421,22 @@ fun ChannelStatistics(stats: ChatStatisticsModel, onLoadGraph: (String) -> Unit) } } +@Composable +fun StoryStatistics(stats: StoryStatisticsModel, onLoadGraph: (String) -> Unit) { + GraphSection( + title = stringResource(R.string.story_statistics_interactions_graph), + graph = stats.storyInteractionGraph, + color = Color(0xFF34A853), + onLoadGraph = onLoadGraph + ) + GraphSection( + title = stringResource(R.string.story_statistics_reactions_graph), + graph = stats.storyReactionGraph, + color = Color(0xFFFBBC04), + onLoadGraph = onLoadGraph + ) +} + @Composable fun InteractionItem(interaction: ChatInteractionInfoModel) { var expanded by remember { mutableStateOf(false) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt index d27bf5839..b0e52faad 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt @@ -8,11 +8,18 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.monogram.domain.models.stories.ActiveStoryListModel import org.monogram.domain.models.stories.StoryComposerDraftModel +import org.monogram.domain.models.stories.StoryComposerMediaItemModel +import org.monogram.domain.models.stories.StoryInteractionActorType +import org.monogram.domain.models.stories.StoryInteractionModel +import org.monogram.domain.models.stories.StoryInteractionPageModel import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.models.stories.StoryMediaType import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryPostResultModel import org.monogram.domain.models.stories.StoryPrivacyMode import org.monogram.domain.models.stories.StoryPrivacySettingsModel +import org.monogram.domain.models.stories.StoryReactionModel +import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.AuthRepository import org.monogram.domain.repository.AuthStep import org.monogram.domain.repository.ChatListRepository @@ -28,17 +35,29 @@ class DefaultStoriesHostComponent( private val storyRepository: StoryRepository = container.repositories.storyRepository private val chatListRepository: ChatListRepository = container.repositories.chatListRepository private val userRepository: UserRepository = container.repositories.userRepository + private val appPreferences: AppPreferencesProvider = + container.preferences.appPreferencesProvider private val messageDisplayer = container.utils.messageDisplayer() + private val clipManager = container.utils.clipManager + private val externalNavigator = container.utils.externalNavigator() + private val stringProvider = container.utils.stringProvider() private val scope = componentScope private var hasLoadedActiveStories = false private var storyLoadJob: Job? = null private var storyRefreshJob: Job? = null + private var storyMediaLoadingMessageJob: Job? = null + private var storyMediaLoadingMessageKey: Pair? = null private val chatPresentationCache = mutableMapOf() - private val _state = MutableStateFlow(StoriesHostComponent.State()) + private val _state = MutableStateFlow(createDefaultState()) override val state = _state.asStateFlow() init { + scope.launch { + appPreferences.storyMediaStretchEnabled.collect { enabled -> + _state.value = _state.value.copy(isStoryMediaStretchEnabled = enabled) + } + } scope.launch { Log.d(TAG, "initializing stories host") authRepository.authState @@ -75,9 +94,20 @@ class DefaultStoriesHostComponent( currentStory = null, activeListType = listType, canManageStories = false, + composerMode = StoryComposerMode.CREATE, + editingStoryId = null, inlineError = null, - showInlineVideo = false + isSubmitting = false, + isStoryStatisticsVisible = false, + isStoryStatisticsLoading = false, + storyStatistics = null, + isStoryInteractionsVisible = false, + isStoryInteractionsLoading = false, + storyInteractionsPage = null, + showInlineVideo = false, + showStoryMediaLoadingMessage = false ) + syncStoryMediaLoadingMessage() Log.d(TAG, "viewer placeholder shown chatId=$chatId listType=$listType") storyLoadJob = scope.launch { Log.d(TAG, "openChatStories chatId=$chatId storyId=$storyId listType=$listType") @@ -123,8 +153,10 @@ class DefaultStoriesHostComponent( currentStory = story, canManageStories = chatPresentation.canManageStories, inlineError = null, - showInlineVideo = false + showInlineVideo = false, + showStoryMediaLoadingMessage = false ) + syncStoryMediaLoadingMessage() scheduleStoryRefreshIfNeeded(item, story) } } @@ -142,9 +174,20 @@ class DefaultStoriesHostComponent( currentStory = null, activeListType = StoryListType.MAIN, canManageStories = false, + composerMode = StoryComposerMode.CREATE, + editingStoryId = null, inlineError = null, - showInlineVideo = false + isSubmitting = false, + isStoryStatisticsVisible = false, + isStoryStatisticsLoading = false, + storyStatistics = null, + isStoryInteractionsVisible = false, + isStoryInteractionsLoading = false, + storyInteractionsPage = null, + showInlineVideo = false, + showStoryMediaLoadingMessage = false ) + syncStoryMediaLoadingMessage() Log.d(TAG, "viewer placeholder shown for album chatId=$chatId albumId=$albumId") scope.launch { Log.d(TAG, "openStoryAlbum chatId=$chatId albumId=$albumId") @@ -170,8 +213,10 @@ class DefaultStoriesHostComponent( currentStory = stories.first(), canManageStories = canManageStories(chatId), inlineError = null, - showInlineVideo = false + showInlineVideo = false, + showStoryMediaLoadingMessage = false ) + syncStoryMediaLoadingMessage() scheduleStoryRefreshIfNeeded(items.first(), stories.first()) } } @@ -182,32 +227,75 @@ class DefaultStoriesHostComponent( initialSourcePath: String?, initialCaption: String, widgetLink: String? + ) { + showComposer( + chatId = chatId, + composerMode = StoryComposerMode.CREATE, + editingStoryId = null, + draft = createComposerDraft( + preferredMediaType = preferredMediaType, + initialSourcePath = initialSourcePath, + initialCaption = initialCaption, + widgetLink = widgetLink + ) + ) + } + + override fun editCurrentStory() { + val current = _state.value + val story = current.currentStory ?: return + val mediaPath = resolveStoryEditableMediaPath(story) + if (!story.canBeEdited || mediaPath == null) { + _state.value = current.copy(inlineError = "This story can't be edited yet") + return + } + + showComposer( + chatId = story.posterChatId, + composerMode = StoryComposerMode.EDIT, + editingStoryId = story.id, + draft = createEditComposerDraft(story, mediaPath) + ) + } + + private fun showComposer( + chatId: Long, + composerMode: StoryComposerMode, + editingStoryId: Int?, + draft: StoryComposerDraftModel ) { storyRefreshJob?.cancel() + syncStoryMediaLoadingMessage(disableOnly = true) _state.value = _state.value.copy( mode = StoriesHostComponent.Mode.Composer, - isLoading = true, + isLoading = composerMode == StoryComposerMode.CREATE, chatId = chatId, chatTitle = "", chatAvatarPath = null, canManageStories = false, - composerDraft = StoryComposerDraftModel( - sourcePath = initialSourcePath.orEmpty(), - mediaType = preferredMediaType ?: inferMediaType(initialSourcePath), - caption = initialCaption, - widgetLink = widgetLink - ), + composerMode = composerMode, + editingStoryId = editingStoryId, + composerDraft = draft, postCapability = null, inlineError = null, - showMediaPicker = initialSourcePath.isNullOrBlank(), - showCamera = false, isSubmitting = false, - showInlineVideo = false + isStoryStatisticsVisible = false, + isStoryStatisticsLoading = false, + storyStatistics = null, + isStoryInteractionsVisible = false, + isStoryInteractionsLoading = false, + storyInteractionsPage = null, + showMediaPicker = !draft.isValid, + showCamera = false, + showInlineVideo = false, + showStoryMediaLoadingMessage = false ) Log.d(TAG, "composer placeholder shown chatId=$chatId") scope.launch { - val capability = storyRepository.canPostStory(chatId) - Log.d(TAG, "openComposer chatId=$chatId capability=$capability") + val capability = + if (composerMode == StoryComposerMode.CREATE) storyRepository.canPostStory(chatId) + else null + Log.d(TAG, "openComposer chatId=$chatId mode=$composerMode capability=$capability") _state.value = _state.value.copy( mode = StoriesHostComponent.Mode.Composer, isLoading = false, @@ -215,18 +303,16 @@ class DefaultStoriesHostComponent( chatTitle = resolveChatTitle(chatId), chatAvatarPath = resolveChatAvatar(chatId), canManageStories = canManageStories(chatId), - composerDraft = StoryComposerDraftModel( - sourcePath = initialSourcePath.orEmpty(), - mediaType = preferredMediaType ?: inferMediaType(initialSourcePath), - caption = initialCaption, - widgetLink = widgetLink - ), + composerMode = composerMode, + editingStoryId = editingStoryId, + composerDraft = draft, postCapability = capability, inlineError = null, - showMediaPicker = initialSourcePath.isNullOrBlank(), + showMediaPicker = !draft.isValid, showCamera = false, isSubmitting = false, - showInlineVideo = false + showInlineVideo = false, + showStoryMediaLoadingMessage = false ) } } @@ -234,12 +320,23 @@ class DefaultStoriesHostComponent( override fun dismiss() { storyLoadJob?.cancel() storyRefreshJob?.cancel() + syncStoryMediaLoadingMessage(disableOnly = true) + val current = _state.value + if ( + current.mode == StoriesHostComponent.Mode.Composer && + current.composerMode == StoryComposerMode.EDIT && + current.currentStory != null && + current.viewerItems.isNotEmpty() + ) { + _state.value = restoreViewerState(current) + return + } scope.launch { state.value.currentStory?.let { storyRepository.closeStory(it.posterChatId, it.id) } } - _state.value = StoriesHostComponent.State() + _state.value = createDefaultState() } override fun nextStory() { @@ -271,17 +368,40 @@ class DefaultStoriesHostComponent( } override fun attachMedia(path: String, mediaType: StoryMediaType) { + val currentDraft = _state.value.composerDraft + val mediaItem = StoryComposerMediaItemModel( + sourcePath = path, + mediaType = mediaType + ) _state.value = _state.value.copy( - composerDraft = _state.value.composerDraft.copy( - sourcePath = path, - mediaType = mediaType - ), + composerDraft = currentDraft.replaceCurrentMedia(mediaItem), showMediaPicker = false, showCamera = false, inlineError = null ) } + override fun attachMedia(items: List) { + if (items.isEmpty()) return + val resolvedItems = if (_state.value.composerMode == StoryComposerMode.EDIT) { + items.take(1) + } else { + items + } + _state.value = _state.value.copy( + composerDraft = _state.value.composerDraft.replaceAllMedia(resolvedItems), + showMediaPicker = false, + showCamera = false, + inlineError = null + ) + } + + override fun selectComposerMedia(index: Int) { + _state.value = _state.value.copy( + composerDraft = _state.value.composerDraft.selectMedia(index) + ) + } + override fun updateCaption(caption: String) { _state.value = _state.value.copy( composerDraft = _state.value.composerDraft.copy(caption = caption) @@ -320,7 +440,7 @@ class DefaultStoriesHostComponent( ) } - override fun submitStory() { + override fun saveStory() { val current = _state.value val chatId = current.chatId ?: return if (!current.composerDraft.isValid) { @@ -330,19 +450,44 @@ class DefaultStoriesHostComponent( _state.value = current.copy(isSubmitting = true, inlineError = null) scope.launch { - Log.d(TAG, "submitStory chatId=$chatId mediaType=${current.composerDraft.mediaType}") - when (val result = storyRepository.postStory(chatId, _state.value.composerDraft)) { - is org.monogram.domain.models.stories.StoryPostResultModel.Success -> { - Log.d(TAG, "submitStory success chatId=$chatId storyId=${result.story.id}") - _state.value = StoriesHostComponent.State() + Log.d( + TAG, + "saveStory chatId=$chatId mode=${current.composerMode} mediaCount=${current.composerDraft.mediaCount} mediaType=${current.composerDraft.mediaType}" + ) + when ( + val result = saveStoryDraft( + storyRepository = storyRepository, + chatId = chatId, + composerMode = current.composerMode, + editingStoryId = current.editingStoryId, + draft = _state.value.composerDraft + ) + ) { + is StorySaveOutcome.Created -> { + Log.d(TAG, "saveStory create success chatId=$chatId storyId=${result.story.id}") + result.message?.let(messageDisplayer::show) + _state.value = createDefaultState() openChatStories(chatId, result.story.id) } - is org.monogram.domain.models.stories.StoryPostResultModel.Failure -> { - Log.d(TAG, "submitStory failed chatId=$chatId message=${result.message}") + is StorySaveOutcome.Edited -> { + Log.d(TAG, "saveStory edit success chatId=$chatId storyId=${result.storyId}") + val listType = current.activeListType + _state.value = restoreViewerState( + _state.value.copy( + isSubmitting = false, + composerMode = StoryComposerMode.CREATE, + editingStoryId = null + ) + ) + openChatStories(chatId, result.storyId, listType) + } + + is StorySaveOutcome.Failed -> { + Log.d(TAG, "saveStory failed chatId=$chatId message=${result.message}") _state.value = _state.value.copy( isSubmitting = false, - inlineError = result.message.ifBlank { "Failed to publish story" } + inlineError = result.message ) } } @@ -394,6 +539,149 @@ class DefaultStoriesHostComponent( } } + override fun showStoryStatistics() { + val story = _state.value.currentStory ?: return + if (!story.canGetStatistics) return + + _state.value = _state.value.copy( + isStoryStatisticsVisible = true, + isStoryStatisticsLoading = true, + storyStatistics = null, + inlineError = null + ) + scope.launch { + val statistics = storyRepository.getStoryStatistics( + chatId = story.posterChatId, + storyId = story.id, + isDark = false + ) + if (statistics == null) { + _state.value = _state.value.copy( + isStoryStatisticsVisible = false, + isStoryStatisticsLoading = false, + storyStatistics = null, + inlineError = "Failed to load story statistics" + ) + } else { + _state.value = _state.value.copy( + isStoryStatisticsVisible = true, + isStoryStatisticsLoading = false, + storyStatistics = statistics, + inlineError = null + ) + } + } + } + + override fun dismissStoryStatistics() { + _state.value = _state.value.copy( + isStoryStatisticsVisible = false, + isStoryStatisticsLoading = false, + storyStatistics = null + ) + } + + override fun showStoryInteractions() { + val story = _state.value.currentStory ?: return + if (!story.canGetInteractions) return + + _state.value = _state.value.copy( + isStoryInteractionsVisible = true, + isStoryInteractionsLoading = true, + storyInteractionsPage = null, + inlineError = null + ) + scope.launch { + val rawPage = storyRepository.getStoryInteractions( + storyId = story.id, + offset = "", + limit = STORY_INTERACTIONS_PAGE_SIZE + ) + val page = rawPage?.let { enrichStoryInteractions(it) } + if (page == null) { + _state.value = _state.value.copy( + isStoryInteractionsVisible = false, + isStoryInteractionsLoading = false, + storyInteractionsPage = null, + inlineError = "Failed to load story interactions" + ) + } else { + _state.value = _state.value.copy( + isStoryInteractionsVisible = true, + isStoryInteractionsLoading = false, + storyInteractionsPage = page, + inlineError = null + ) + } + } + } + + override fun dismissStoryInteractions() { + _state.value = _state.value.copy( + isStoryInteractionsVisible = false, + isStoryInteractionsLoading = false, + storyInteractionsPage = null + ) + } + + override fun loadMoreStoryInteractions() { + val current = _state.value + val story = current.currentStory ?: return + val page = current.storyInteractionsPage ?: return + if (current.isStoryInteractionsLoading || !page.canLoadMore || !story.canGetInteractions) { + return + } + + _state.value = current.copy(isStoryInteractionsLoading = true, inlineError = null) + scope.launch { + val rawNextPage = storyRepository.getStoryInteractions( + storyId = story.id, + offset = page.nextOffset, + limit = STORY_INTERACTIONS_PAGE_SIZE + ) + val nextPage = rawNextPage?.let { enrichStoryInteractions(it) } + if (nextPage == null) { + _state.value = _state.value.copy( + isStoryInteractionsLoading = false, + inlineError = "Failed to load more story interactions" + ) + } else { + _state.value = _state.value.copy( + isStoryInteractionsLoading = false, + storyInteractionsPage = page.mergeWith(nextPage), + inlineError = null + ) + } + } + } + + override fun openStoryLink(url: String) { + externalNavigator.openUrl(url) + } + + override fun copyStoryLink(url: String) { + clipManager.copyToClipboard("story_link", url) + messageDisplayer.show(stringProvider.getString("link_copied")) + } + + override fun setStoryReaction(reaction: StoryReactionModel) { + val story = _state.value.currentStory ?: return + scope.launch { + val success = storyRepository.setStoryReaction( + chatId = story.posterChatId, + storyId = story.id, + reaction = reaction + ) + if (!success) { + _state.value = _state.value.copy(inlineError = "Failed to send story reaction") + } + } + } + + override fun setStoryMediaStretchEnabled(enabled: Boolean) { + appPreferences.setStoryMediaStretchEnabled(enabled) + } + override fun dismissInlineVideo() { _state.value = _state.value.copy(showInlineVideo = false) } @@ -418,9 +706,20 @@ class DefaultStoriesHostComponent( currentStory = null, isLoading = true, canManageStories = cachedPresentation?.canManageStories ?: false, + composerMode = StoryComposerMode.CREATE, + editingStoryId = null, inlineError = null, - showInlineVideo = false + isSubmitting = false, + isStoryStatisticsVisible = false, + isStoryStatisticsLoading = false, + storyStatistics = null, + isStoryInteractionsVisible = false, + isStoryInteractionsLoading = false, + storyInteractionsPage = null, + showInlineVideo = false, + showStoryMediaLoadingMessage = false ) + syncStoryMediaLoadingMessage() current.currentStory?.let { previousStory -> scope.launch { storyRepository.closeStory(previousStory.posterChatId, previousStory.id) @@ -438,8 +737,10 @@ class DefaultStoriesHostComponent( isLoading = story.requiresMediaRefresh(), canManageStories = chatPresentation.canManageStories, inlineError = if (story == null) "Unable to load story" else null, - showInlineVideo = false + showInlineVideo = false, + showStoryMediaLoadingMessage = false ) + syncStoryMediaLoadingMessage() scheduleStoryRefreshIfNeeded(item, story) } } @@ -487,25 +788,59 @@ class DefaultStoriesHostComponent( _state.value = _state.value.copy( currentStory = refreshed, isLoading = refreshed.requiresMediaRefresh(), - inlineError = null + inlineError = null, + showStoryMediaLoadingMessage = false ) + syncStoryMediaLoadingMessage() } if (refreshed?.requiresMediaRefresh() == false) { return@launch } } + } + } - val current = _state.value - if ( + private fun syncStoryMediaLoadingMessage(disableOnly: Boolean = false) { + val current = _state.value + val currentItem = current.viewerItems.getOrNull(current.viewerIndex) + val shouldTrack = !disableOnly && current.mode == StoriesHostComponent.Mode.Viewer && - current.chatId == item.chatId && - current.viewerItems.getOrNull(current.viewerIndex)?.storyId == item.storyId && + current.isLoading && + currentItem != null && current.currentStory.requiresMediaRefresh() + + if (!shouldTrack) { + storyMediaLoadingMessageJob?.cancel() + storyMediaLoadingMessageJob = null + storyMediaLoadingMessageKey = null + if (current.showStoryMediaLoadingMessage) { + _state.value = current.copy(showStoryMediaLoadingMessage = false) + } + return + } + + val loadingKey = currentItem.chatId to currentItem.storyId + if (storyMediaLoadingMessageKey == loadingKey && storyMediaLoadingMessageJob?.isActive == true) { + return + } + + storyMediaLoadingMessageJob?.cancel() + storyMediaLoadingMessageKey = loadingKey + if (current.showStoryMediaLoadingMessage) { + _state.value = current.copy(showStoryMediaLoadingMessage = false) + } + storyMediaLoadingMessageJob = scope.launch { + delay(60_000) + val latest = _state.value + val latestItem = latest.viewerItems.getOrNull(latest.viewerIndex) + if ( + latest.mode == StoriesHostComponent.Mode.Viewer && + latest.isLoading && + latestItem?.chatId == loadingKey.first && + latestItem.storyId == loadingKey.second && + latest.currentStory.requiresMediaRefresh() ) { - _state.value = current.copy( - isLoading = false, - inlineError = "Media is still loading" - ) + _state.value = latest.copy(showStoryMediaLoadingMessage = true) } } } @@ -542,6 +877,48 @@ class DefaultStoriesHostComponent( return me?.id == chatId || chat?.isAdmin == true } + private suspend fun enrichStoryInteractions( + page: StoryInteractionPageModel + ): StoryInteractionPageModel { + val enrichedInteractions = mutableListOf() + for (interaction in page.interactions) { + enrichedInteractions += enrichStoryInteraction(interaction) + } + return page.copy( + interactions = enrichedInteractions + ) + } + + private suspend fun enrichStoryInteraction( + interaction: StoryInteractionModel + ): StoryInteractionModel { + return when (interaction.actorType) { + StoryInteractionActorType.USER -> { + val user = userRepository.getUser(interaction.actorId) + val fullName = listOfNotNull(user?.firstName, user?.lastName) + .joinToString(" ") + .trim() + val fallbackTitle = user?.username + ?.takeIf { it.isNotBlank() } + ?.let { "@$it" } + ?: interaction.actorId.toString() + interaction.copy( + actorTitle = fullName.ifBlank { interaction.actorTitle ?: fallbackTitle }, + actorAvatarPath = user?.avatarPath + ) + } + + StoryInteractionActorType.CHAT -> { + val chat = chatListRepository.getChatById(interaction.actorId) + interaction.copy( + actorTitle = chat?.title?.ifBlank { interaction.actorId.toString() } + ?: interaction.actorId.toString(), + actorAvatarPath = chat?.avatarPath + ) + } + } + } + private fun inferMediaType(sourcePath: String?): StoryMediaType { val normalized = sourcePath.orEmpty().lowercase() return if ( @@ -556,9 +933,220 @@ class DefaultStoriesHostComponent( } } + private fun createDefaultState(): StoriesHostComponent.State { + return StoriesHostComponent.State( + isStoryMediaStretchEnabled = appPreferences.storyMediaStretchEnabled.value + ) + } + companion object { private const val TAG = "StoriesHostDiag" + private const val STORY_INTERACTIONS_PAGE_SIZE = 50 + } +} + +internal fun createComposerDraft( + preferredMediaType: StoryMediaType? = null, + initialSourcePath: String? = null, + initialCaption: String = "", + widgetLink: String? = null +): StoryComposerDraftModel { + val mediaItems = initialSourcePath + ?.takeIf { it.isNotBlank() } + ?.let { path -> + listOf( + StoryComposerMediaItemModel( + sourcePath = path, + mediaType = preferredMediaType ?: inferStoryComposerMediaType(path) + ) + ) + } + .orEmpty() + return StoryComposerDraftModel( + mediaItems = mediaItems, + caption = initialCaption, + widgetLink = widgetLink + ) +} + +internal fun createEditComposerDraft( + story: StoryModel, + mediaPath: String +): StoryComposerDraftModel { + return StoryComposerDraftModel( + mediaItems = listOf( + StoryComposerMediaItemModel( + sourcePath = mediaPath, + mediaType = story.media.type + ) + ), + caption = story.caption, + privacy = story.privacy, + widgetLink = story.linkUrls.firstOrNull() + ) +} + +internal fun resolveStoryEditableMediaPath(story: StoryModel?): String? { + return story?.media?.path?.takeIf { it.isNotBlank() } +} + +private fun restoreViewerState(state: StoriesHostComponent.State): StoriesHostComponent.State { + return state.copy( + mode = StoriesHostComponent.Mode.Viewer, + isLoading = false, + composerMode = StoryComposerMode.CREATE, + editingStoryId = null, + postCapability = null, + inlineError = null, + isSubmitting = false, + isStoryStatisticsVisible = false, + isStoryStatisticsLoading = false, + storyStatistics = null, + isStoryInteractionsVisible = false, + isStoryInteractionsLoading = false, + storyInteractionsPage = null, + showMediaPicker = false, + showCamera = false + ) +} + +private fun StoryInteractionPageModel.mergeWith( + nextPage: StoryInteractionPageModel +): StoryInteractionPageModel { + return copy( + totalCount = nextPage.totalCount, + totalForwardCount = nextPage.totalForwardCount, + totalReactionCount = nextPage.totalReactionCount, + interactions = interactions + nextPage.interactions, + nextOffset = nextPage.nextOffset + ) +} + +internal sealed class StorySaveOutcome { + data class Created( + val story: StoryModel, + val message: String? = null + ) : StorySaveOutcome() + + data class Edited(val storyId: Int) : StorySaveOutcome() + data class Failed(val message: String) : StorySaveOutcome() +} + +internal suspend fun saveStoryDraft( + storyRepository: StoryRepository, + chatId: Long, + composerMode: StoryComposerMode, + editingStoryId: Int?, + draft: StoryComposerDraftModel +): StorySaveOutcome { + return when (composerMode) { + StoryComposerMode.CREATE -> { + val mediaItems = draft.mediaItems + if (mediaItems.isEmpty()) { + StorySaveOutcome.Failed("Pick a photo or video first") + } else { + var lastStory: StoryModel? = null + var createdCount = 0 + for (mediaItem in mediaItems) { + when (val result = + storyRepository.postStory(chatId, draft.forSingleMedia(mediaItem))) { + is StoryPostResultModel.Success -> { + lastStory = result.story + createdCount += 1 + } + + is StoryPostResultModel.Failure -> { + if (lastStory != null && createdCount > 0) { + return StorySaveOutcome.Created( + story = lastStory, + message = "Published $createdCount stories. ${result.message.ifBlank { "Stopped before the remaining items" }}" + ) + } + return StorySaveOutcome.Failed( + result.message.ifBlank { "Failed to publish story" } + ) + } + } + } + + StorySaveOutcome.Created( + story = lastStory ?: return StorySaveOutcome.Failed("Failed to publish story"), + message = if (createdCount > 1) { + "Published $createdCount stories" + } else { + null + } + ) + } + } + + StoryComposerMode.EDIT -> { + val storyId = editingStoryId + ?: return StorySaveOutcome.Failed("Missing story to edit") + if (storyRepository.editStory(chatId, storyId, draft)) { + StorySaveOutcome.Edited(storyId) + } else { + StorySaveOutcome.Failed("Failed to save story") + } + } + } +} + +private fun inferStoryComposerMediaType(sourcePath: String?): StoryMediaType { + val normalized = sourcePath.orEmpty().lowercase() + return if ( + normalized.endsWith(".mp4") || + normalized.endsWith(".mov") || + normalized.endsWith(".webm") || + normalized.endsWith(".mkv") + ) { + StoryMediaType.VIDEO + } else { + StoryMediaType.PHOTO + } +} + +private fun StoryComposerDraftModel.replaceCurrentMedia( + mediaItem: StoryComposerMediaItemModel +): StoryComposerDraftModel { + if (mediaItems.isEmpty()) { + return copy( + mediaItems = listOf(mediaItem), + selectedMediaIndex = 0 + ) + } + + val safeIndex = selectedMediaIndex.coerceIn(0, mediaItems.lastIndex) + val updatedItems = mediaItems.toMutableList().apply { + this[safeIndex] = mediaItem } + return copy( + mediaItems = updatedItems, + selectedMediaIndex = safeIndex + ) +} + +private fun StoryComposerDraftModel.replaceAllMedia( + items: List +): StoryComposerDraftModel { + return copy( + mediaItems = items, + selectedMediaIndex = 0 + ) +} + +private fun StoryComposerDraftModel.selectMedia(index: Int): StoryComposerDraftModel { + if (mediaItems.isEmpty()) return copy(selectedMediaIndex = 0) + return copy(selectedMediaIndex = index.coerceIn(0, mediaItems.lastIndex)) +} + +private fun StoryComposerDraftModel.forSingleMedia( + mediaItem: StoryComposerMediaItemModel +): StoryComposerDraftModel { + return copy( + mediaItems = listOf(mediaItem), + selectedMediaIndex = 0 + ) } internal fun buildViewerItems(activeStories: List): List { diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt index 95c97f89c..b225da997 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt @@ -3,10 +3,14 @@ package org.monogram.presentation.features.stories import kotlinx.coroutines.flow.StateFlow import org.monogram.domain.models.stories.ActiveStoryListModel import org.monogram.domain.models.stories.StoryComposerDraftModel +import org.monogram.domain.models.stories.StoryComposerMediaItemModel +import org.monogram.domain.models.stories.StoryInteractionPageModel import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.models.stories.StoryMediaType import org.monogram.domain.models.stories.StoryModel import org.monogram.domain.models.stories.StoryPostCapabilityModel +import org.monogram.domain.models.stories.StoryReactionModel +import org.monogram.domain.models.stories.StoryStatisticsModel interface StoriesHostComponent { val state: StateFlow @@ -34,15 +38,27 @@ interface StoriesHostComponent { fun showCamera() fun dismissCamera() fun attachMedia(path: String, mediaType: StoryMediaType) + fun attachMedia(items: List) + fun selectComposerMedia(index: Int) fun updateCaption(caption: String) fun updatePrivacy(mode: StoryPrivacyUi) fun updateActivePeriod(seconds: Int) fun updateProtectContent(protectContent: Boolean) fun updateKeepOnProfile(keepOnProfile: Boolean) - fun submitStory() + fun saveStory() + fun editCurrentStory() fun deleteCurrentStory() fun moveCurrentStoryToArchive() fun restoreCurrentStoryFromArchive() + fun showStoryStatistics() + fun dismissStoryStatistics() + fun showStoryInteractions() + fun dismissStoryInteractions() + fun loadMoreStoryInteractions() + fun openStoryLink(url: String) + fun copyStoryLink(url: String) + fun setStoryReaction(reaction: StoryReactionModel) + fun setStoryMediaStretchEnabled(enabled: Boolean) fun dismissInlineVideo() fun showInlineVideo() @@ -57,13 +73,23 @@ interface StoriesHostComponent { val currentStory: StoryModel? = null, val activeListType: StoryListType = StoryListType.MAIN, val canManageStories: Boolean = false, + val composerMode: StoryComposerMode = StoryComposerMode.CREATE, + val editingStoryId: Int? = null, val composerDraft: StoryComposerDraftModel = StoryComposerDraftModel(), val postCapability: StoryPostCapabilityModel? = null, val inlineError: String? = null, val isSubmitting: Boolean = false, + val isStoryStatisticsVisible: Boolean = false, + val isStoryStatisticsLoading: Boolean = false, + val storyStatistics: StoryStatisticsModel? = null, + val isStoryInteractionsVisible: Boolean = false, + val isStoryInteractionsLoading: Boolean = false, + val storyInteractionsPage: StoryInteractionPageModel? = null, val showMediaPicker: Boolean = false, val showCamera: Boolean = false, - val showInlineVideo: Boolean = false + val showInlineVideo: Boolean = false, + val showStoryMediaLoadingMessage: Boolean = false, + val isStoryMediaStretchEnabled: Boolean = true ) { val isVisible: Boolean get() = mode != Mode.Hidden @@ -82,6 +108,11 @@ interface StoriesHostComponent { } } +enum class StoryComposerMode { + CREATE, + EDIT +} + data class StoryViewerUiModel( val chatId: Long, val storyId: Int, diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt index 360b106a2..f717a1850 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt @@ -1,15 +1,11 @@ package org.monogram.presentation.features.stories -import android.Manifest import android.app.Activity import android.content.Context import android.content.ContextWrapper -import android.content.pm.PackageManager import android.net.Uri import android.text.format.DateFormat import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutSlowInEasing @@ -26,133 +22,66 @@ import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.automirrored.rounded.VolumeOff -import androidx.compose.material.icons.automirrored.rounded.VolumeUp -import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Archive -import androidx.compose.material.icons.rounded.CameraAlt +import androidx.compose.material.icons.rounded.BarChart import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Download -import androidx.compose.material.icons.rounded.Favorite -import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Link -import androidx.compose.material.icons.rounded.Pause import androidx.compose.material.icons.rounded.PeopleAlt -import androidx.compose.material.icons.rounded.PhotoLibrary -import androidx.compose.material.icons.rounded.PlayArrow -import androidx.compose.material.icons.rounded.Public +import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Restore -import androidx.compose.material.icons.rounded.Schedule -import androidx.compose.material.icons.rounded.Shield import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex -import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.media3.common.MediaItem -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.ui.AspectRatioFrameLayout -import androidx.media3.ui.PlayerView import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import kotlinx.coroutines.delay +import org.monogram.domain.models.stories.StoryAreaTypeModel +import org.monogram.domain.models.stories.StoryInteractionPageModel +import org.monogram.domain.models.stories.StoryInteractionTypeModel import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.models.stories.StoryMediaType import org.monogram.domain.models.stories.StoryModel import org.monogram.domain.models.stories.StoryPostCapabilityModel -import org.monogram.domain.models.stories.StoryPrivacyMode +import org.monogram.domain.models.stories.StoryReactionModel import org.monogram.presentation.R -import org.monogram.presentation.features.camera.CameraScreen -import org.monogram.presentation.features.chats.conversation.ui.inputbar.copyUriToTempPath -import org.monogram.presentation.features.chats.conversation.ui.inputbar.declaredPermissions -import org.monogram.presentation.features.chats.conversation.ui.inputbar.hasAllPermissions -import org.monogram.presentation.features.gallery.GalleryScreen import org.monogram.presentation.features.viewers.VideoViewer -import java.io.File import java.util.Date +import kotlin.math.absoluteValue import kotlin.math.roundToInt @Composable @@ -297,258 +226,15 @@ private fun StoryViewerScaffold( component: StoriesHostComponent, story: StoryModel? ) { - val context = LocalContext.current - val downloadUtils: org.monogram.presentation.core.util.IDownloadUtils = - org.koin.compose.koinInject() - var currentProgress by remember(story?.id) { mutableFloatStateOf(0f) } - var restartPlaybackToken by remember(story?.id) { mutableStateOf(0) } - var isVideoMuted by remember(story?.id) { mutableStateOf(false) } - var isVideoPaused by remember(story?.id) { mutableStateOf(false) } - var isVideoBuffering by remember(story?.id) { mutableStateOf(story?.media?.type == StoryMediaType.VIDEO) } - var isVideoPlaying by remember(story?.id) { mutableStateOf(false) } - val advanceStory by rememberUpdatedState(newValue = { - if (state.canGoNext) { - component.nextStory() - } else { - component.dismiss() - } - }) - - LaunchedEffect(story?.id, restartPlaybackToken, state.isLoading, story?.media?.type) { - currentProgress = 0f - if (story == null || state.isLoading || story.media.type != StoryMediaType.PHOTO) return@LaunchedEffect - val totalDurationMs = resolveStoryAutoAdvanceDurationMs(story) - val startMs = System.currentTimeMillis() - while (currentProgress < 1f) { - val elapsedMs = (System.currentTimeMillis() - startMs).coerceAtLeast(0L) - currentProgress = (elapsedMs.toFloat() / totalDurationMs.toFloat()).coerceIn(0f, 1f) - if (currentProgress >= 1f) break - delay(16) - } - advanceStory() - } - - val pageState = remember(story, state.viewerIndex, state.isLoading, state.inlineError) { - StoryViewerPageState( - story = story, - viewerIndex = state.viewerIndex, - isLoading = state.isLoading, - inlineError = state.inlineError - ) - } - - Box(modifier = Modifier.fillMaxSize()) { - StoryViewerSystemBars() - StoryViewerBackground() - StoryStatusBarScrim() - - AnimatedContent( - targetState = pageState, - transitionSpec = { - fadeIn(tween(110)) togetherWith fadeOut(tween(90)) - }, - contentKey = { current -> current.story?.id ?: "story-${current.viewerIndex}" }, - label = "story_viewer_page" - ) { currentPage -> - StoryMediaScene( - context = context, - state = state, - page = currentPage, - progress = currentProgress, - isVideoMuted = isVideoMuted, - isVideoPaused = isVideoPaused, - restartPlaybackToken = restartPlaybackToken, - onVideoMutedChange = { isVideoMuted = it }, - onVideoPausedChange = { isVideoPaused = it }, - onVideoProgress = { progressValue -> - currentProgress = progressValue.coerceIn(0f, 1f) - }, - onVideoBufferingChange = { isVideoBuffering = it }, - onVideoPlayingChange = { isVideoPlaying = it }, - onVideoCompleted = advanceStory - ) - } - - Row(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .clickable( - enabled = story != null, - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = { - if (shouldRestartCurrentStoryFromPreviousTap(currentProgress) || !state.canGoPrevious) { - restartPlaybackToken += 1 - isVideoPaused = false - } else { - component.previousStory() - } - }) - ) - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .clickable( - enabled = story != null, - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = { - if (state.canGoNext) component.nextStory() else component.dismiss() - }) - ) - } - - StoryViewerChrome( - state = state, - story = story, - progress = currentProgress, - isVideo = story?.media?.type == StoryMediaType.VIDEO, - isVideoPaused = isVideoPaused, - isVideoMuted = isVideoMuted, - isVideoBuffering = isVideoBuffering, - isVideoPlaying = isVideoPlaying, - onBack = component::dismiss, - onArchive = component::moveCurrentStoryToArchive, - onRestore = component::restoreCurrentStoryFromArchive, - onDelete = component::deleteCurrentStory, - onPauseToggle = { isVideoPaused = !isVideoPaused }, - onMuteToggle = { isVideoMuted = !isVideoMuted }, - onDownload = { - resolveStoryDownloadPath(story)?.let(downloadUtils::saveFileToDownloads) - } - ) - } -} - -@Composable -private fun StoryViewerBackground() { - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = listOf( - Color(0xFF090A0E), - Color(0xFF10131B), - Color(0xFF06070B) - ) - ) - ) + StoryViewerScaffoldComponent( + state = state, + component = component, + story = story ) } @Composable -private fun StoryMediaScene( - context: Context, - state: StoriesHostComponent.State, - page: StoryViewerPageState, - progress: Float, - isVideoMuted: Boolean, - isVideoPaused: Boolean, - restartPlaybackToken: Int, - onVideoMutedChange: (Boolean) -> Unit, - onVideoPausedChange: (Boolean) -> Unit, - onVideoProgress: (Float) -> Unit, - onVideoBufferingChange: (Boolean) -> Unit, - onVideoPlayingChange: (Boolean) -> Unit, - onVideoCompleted: () -> Unit -) { - val story = page.story - when { - story == null && page.isLoading -> { - StoryMediaWaitingPlaceholder() - } - - story == null -> { - StoryUnavailablePlaceholder( - page.inlineError ?: stringResource(R.string.story_viewer_unavailable) - ) - } - - story.media.type == StoryMediaType.PHOTO -> { - val storyImageModel = rememberStoryImageModel( - context = context, - primaryPath = story.media.path, - fallbackPath = story.media.previewPath, - minithumbnail = story.media.minithumbnail - ) - if (storyImageModel != null) { - AsyncImage( - model = storyImageModel, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - if (page.isLoading) { - StoryMediaLoadingOverlay() - } - } else if (page.isLoading) { - StoryMediaWaitingPlaceholder() - } else { - StoryUnavailablePlaceholder( - page.inlineError ?: stringResource(R.string.story_viewer_unavailable) - ) - } - } - - else -> { - val previewModel = rememberStoryImageModel( - context = context, - primaryPath = story.media.previewPath, - fallbackPath = story.media.path, - minithumbnail = story.media.minithumbnail - ) - if (!story.media.path.isNullOrBlank()) { - StoryInlineVideoPlayer( - path = story.media.path.orEmpty(), - previewModel = previewModel, - isMuted = isVideoMuted, - isPlaying = !isVideoPaused, - restartPlaybackToken = restartPlaybackToken, - onProgress = onVideoProgress, - onBufferingChange = onVideoBufferingChange, - onPlayingChange = onVideoPlayingChange, - onCompleted = onVideoCompleted - ) - } else if (previewModel != null) { - AsyncImage( - model = previewModel, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - StoryMediaLoadingOverlay() - } else if (page.isLoading) { - StoryMediaWaitingPlaceholder() - } else { - StoryUnavailablePlaceholder( - page.inlineError ?: stringResource(R.string.story_viewer_unavailable) - ) - } - - } - } - - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = listOf( - Color.Black.copy(alpha = 0.40f), - Color.Transparent, - Color.Black.copy(alpha = 0.58f) - ) - ) - ) - ) -} - -@Composable -private fun StoryViewerChrome( +internal fun StoryViewerChrome( state: StoriesHostComponent.State, story: StoryModel?, progress: Float, @@ -557,280 +243,103 @@ private fun StoryViewerChrome( isVideoMuted: Boolean, isVideoBuffering: Boolean, isVideoPlaying: Boolean, + isMediaScaledToFill: Boolean, onBack: () -> Unit, + onPauseToggle: () -> Unit, + onMuteToggle: () -> Unit, + onReactionClick: () -> Unit, + onMediaScaleToggle: (Boolean) -> Unit, + onLinks: () -> Unit, + onEdit: () -> Unit, onArchive: () -> Unit, onRestore: () -> Unit, + onStatistics: () -> Unit, onDelete: () -> Unit, - onPauseToggle: () -> Unit, - onMuteToggle: () -> Unit, onDownload: () -> Unit ) { - val currentChatId = state.chatId - val currentChatItems = remember(state.viewerItems, currentChatId) { - state.viewerItems.filter { it.chatId == currentChatId } - } - val currentChatIndex = remember(state.viewerItems, currentChatId, state.viewerIndex) { - val currentItem = state.viewerItems.getOrNull(state.viewerIndex) - if (currentItem == null || currentChatId == null) { - 0 - } else { - currentChatItems.indexOfFirst { - it.chatId == currentItem.chatId && it.storyId == currentItem.storyId - }.coerceAtLeast(0) - } - } - - Column( - modifier = Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.statusBars) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalArrangement = Arrangement.SpaceBetween - ) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - StoryViewerProgressRow( - total = currentChatItems.size, - currentIndex = currentChatIndex, - currentProgress = progress - ) - StoryViewerHeader( - state = state, - story = story, - onBack = onBack, - onArchive = onArchive, - onRestore = onRestore, - onDelete = onDelete - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - AnimatedVisibility(visible = isVideo) { - StoryTopIconButton( - onClick = onMuteToggle, - contentDescription = if (isVideoMuted) { - stringResource(R.string.menu_unmute) - } else { - stringResource(R.string.menu_mute) - } - ) { - Icon( - imageVector = if (isVideoMuted) { - Icons.AutoMirrored.Rounded.VolumeOff - } else { - Icons.AutoMirrored.Rounded.VolumeUp - }, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } - AnimatedVisibility(visible = isVideo) { - StoryTopIconButton( - onClick = onPauseToggle, - contentDescription = if (isVideoPaused || !isVideoPlaying) { - stringResource(R.string.action_play) - } else { - stringResource(R.string.action_pause) - } - ) { - if (isVideoBuffering) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.5.dp, - color = MaterialTheme.colorScheme.onPrimary - ) - } else { - Icon( - imageVector = if (isVideoPaused || !isVideoPlaying) { - Icons.Rounded.PlayArrow - } else { - Icons.Rounded.Pause - }, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } - } - StoryTopIconButton( - onClick = onDownload, - contentDescription = stringResource(R.string.action_download) - ) { - Icon( - imageVector = Icons.Rounded.Download, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } - } - } - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - AnimatedVisibility(visible = state.inlineError != null) { - StoryErrorBanner(message = state.inlineError.orEmpty()) - } + StoryViewerChromeComponent( + state = state, + story = story, + progress = progress, + isVideo = isVideo, + isVideoPaused = isVideoPaused, + isVideoMuted = isVideoMuted, + isVideoBuffering = isVideoBuffering, + isVideoPlaying = isVideoPlaying, + isMediaScaledToFill = isMediaScaledToFill, + onBack = onBack, + onPauseToggle = onPauseToggle, + onMuteToggle = onMuteToggle, + onReactionClick = onReactionClick, + onMediaScaleToggle = onMediaScaleToggle, + onLinks = onLinks, + onEdit = onEdit, + onArchive = onArchive, + onRestore = onRestore, + onStatistics = onStatistics, + onDelete = onDelete, + onDownload = onDownload + ) +} - AnimatedVisibility(visible = !story?.caption.isNullOrBlank()) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = Color.Black.copy(alpha = 0.38f) - ) { - Text( - text = story?.caption.orEmpty(), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 12.dp), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimary - ) - } - } - } - } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun StoryLinksSheet( + urls: List, + onDismiss: () -> Unit, + onOpenLink: (String) -> Unit, + onCopyLink: (String) -> Unit +) { + StoryLinksSheetComponent( + urls = urls, + onDismiss = onDismiss, + onOpenLink = onOpenLink, + onCopyLink = onCopyLink + ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun StoryViewerHeader( - state: StoriesHostComponent.State, - story: StoryModel?, - onBack: () -> Unit, - onArchive: () -> Unit, - onRestore: () -> Unit, - onDelete: () -> Unit +internal fun StoryInteractionsSheet( + page: StoryInteractionPageModel?, + isLoading: Boolean, + onDismiss: () -> Unit, + onLoadMore: () -> Unit ) { - val context = LocalContext.current - val showSkeleton = state.chatTitle.isBlank() - val selectedItem = state.viewerItems.getOrNull(state.viewerIndex) - val headerInfo = remember( - state.chatId, - state.chatTitle, - selectedItem?.storyId, - selectedItem?.date, - state.viewerIndex, - state.viewerItems - ) { - StoryHeaderInfoState( - title = state.chatTitle, - storyId = selectedItem?.storyId, - positionText = buildStoryPositionText(state), - postedAt = selectedItem?.date?.let { formatStoryPostedTime(context, it) }.orEmpty() - ) - } + StoryInteractionsSheetComponent( + page = page, + isLoading = isLoading, + onDismiss = onDismiss, + onLoadMore = onLoadMore + ) +} +@Composable +internal fun StoryInteractionAvatar( + avatarPath: String?, + isChat: Boolean +) { Surface( - shape = RoundedCornerShape(22.dp), - color = Color.Black.copy(alpha = 0.30f) + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.size(40.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 10.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Surface( - shape = CircleShape, - color = if (showSkeleton) Color.White.copy(alpha = 0.16f) else Color.White.copy( - alpha = 0.12f - ), - modifier = Modifier.size(38.dp) + if (avatarPath != null) { + AsyncImage( + model = avatarPath, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { - if (showSkeleton) { - StorySkeletonPlaceholder() - } else if (state.chatAvatarPath != null) { - AsyncImage( - model = state.chatAvatarPath, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } else { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Rounded.Image, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.9f) - ) - } - } - } - - Box(modifier = Modifier.weight(1f)) { - AnimatedContent( - targetState = headerInfo, - transitionSpec = { - (fadeIn(tween(180)) + slideInVertically { it / 4 }) togetherWith - (fadeOut(tween(120)) + slideOutVertically { -it / 4 }) - }, - label = "story_header_info" - ) { currentInfo -> - if (showSkeleton) { - StoryHeaderSkeleton() - } else { - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = currentInfo.title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = buildString { - append(currentInfo.positionText) - if (currentInfo.postedAt.isNotBlank()) { - append(" • ") - append(currentInfo.postedAt) - } - }, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.72f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - } - - if (state.canManageStories) { - IconButton( - onClick = if (state.activeListType == StoryListType.MAIN) onArchive else onRestore - ) { - Icon( - imageVector = if (state.activeListType == StoryListType.MAIN) { - Icons.Rounded.Archive - } else { - Icons.Rounded.Restore - }, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } - - if (story?.canBeDeleted == true) { - IconButton(onClick = onDelete) { - Icon( - imageVector = Icons.Rounded.Delete, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } - - IconButton(onClick = onBack) { Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + imageVector = if (isChat) Icons.Rounded.PeopleAlt else Icons.Rounded.Person, contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -838,7 +347,7 @@ private fun StoryViewerHeader( } @Composable -private fun StoryMetadataChip( +internal fun StoryMetadataChip( icon: androidx.compose.ui.graphics.vector.ImageVector, label: String ) { @@ -865,27 +374,7 @@ private fun StoryMetadataChip( } @Composable -private fun StoryUnavailablePlaceholder(message: String) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Surface( - shape = RoundedCornerShape(30.dp), - color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.82f) - ) { - Text( - text = message, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface - ) - } - } -} - -@Composable -private fun StoryErrorBanner(message: String) { +internal fun StoryErrorBanner(message: String) { Surface( shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.96f) @@ -899,594 +388,16 @@ private fun StoryErrorBanner(message: String) { } } -@Composable -private fun rememberStoryImageModel( - context: Context, - primaryPath: String?, - fallbackPath: String?, - minithumbnail: ByteArray? -): Any? { - val resolvedPath = remember(primaryPath, fallbackPath) { - listOfNotNull( - primaryPath?.takeIf { it.isNotBlank() }, - fallbackPath?.takeIf { it.isNotBlank() } - ).firstOrNull() - } - - if (resolvedPath == null && minithumbnail != null && minithumbnail.isNotEmpty()) { - return remember(minithumbnail) { - ImageRequest.Builder(context) - .data(minithumbnail) - .build() - } - } - - resolvedPath ?: return null - - return remember(resolvedPath) { - if ( - resolvedPath.startsWith("http://") || - resolvedPath.startsWith("https://") || - resolvedPath.startsWith("content:") || - resolvedPath.startsWith("file:") - ) { - resolvedPath - } else { - val file = File(resolvedPath) - if (file.exists()) { - ImageRequest.Builder(context) - .data(file) - .memoryCacheKey("${file.absolutePath}:${file.lastModified()}:${file.length()}") - .diskCacheKey("${file.absolutePath}:${file.lastModified()}:${file.length()}") - .build() - } else { - ImageRequest.Builder(context) - .data(resolvedPath) - .build() - } - } - } -} - -@Composable -private fun StoryMediaWaitingPlaceholder() { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.20f)), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.colorScheme.onPrimary) - } -} - -@Composable -private fun StoryMediaLoadingOverlay() { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.10f)), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(28.dp), - strokeWidth = 2.5.dp, - color = MaterialTheme.colorScheme.onPrimary - ) - } -} - -@Composable -private fun StoryStatusBarScrim() { - Box( - modifier = Modifier - .fillMaxWidth() - .height(120.dp) - .background( - Brush.verticalGradient( - colors = listOf( - Color.Black.copy(alpha = 0.50f), - Color.Black.copy(alpha = 0.24f), - Color.Transparent - ) - ) - ) - ) -} - -@Composable -private fun StoryViewerProgressRow( - total: Int, - currentIndex: Int, - currentProgress: Float -) { - if (total <= 0) return - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - repeat(total) { index -> - val progress = when { - index < currentIndex -> 1f - index == currentIndex -> currentProgress.coerceIn(0f, 1f) - else -> 0f - } - Box( - modifier = Modifier - .weight(1f) - .height(4.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.22f)) - ) { - Box( - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth(progress) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.onPrimary) - ) - } - } - } -} - @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun StoryComposerOverlay( state: StoriesHostComponent.State, component: StoriesHostComponent ) { - val context = LocalContext.current - val scrollState = rememberScrollState() - val capability = state.postCapability.toCapabilityPresentation() - - val galleryPermissions = remember { - when { - android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> listOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO, - Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED - ) - - android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU -> listOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO - ) - - else -> listOf(Manifest.permission.READ_EXTERNAL_STORAGE) - } - } - val fullGalleryPermissions = remember { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { - listOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO) - } else { - listOf(Manifest.permission.READ_EXTERNAL_STORAGE) - } - } - val requestableGalleryPermissions = remember(context, galleryPermissions) { - val declared = context.declaredPermissions() - galleryPermissions.filter { it in declared } - } - val requestableFullGalleryPermissions = remember(context, fullGalleryPermissions) { - val declared = context.declaredPermissions() - fullGalleryPermissions.filter { it in declared } - } - - fun hasPartialGalleryPermission(): Boolean { - return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE && - ContextCompat.checkSelfPermission( - context, - Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED - ) == PackageManager.PERMISSION_GRANTED - } - - fun hasFullGalleryPermission(): Boolean { - return requestableFullGalleryPermissions.isEmpty() || - context.hasAllPermissions(requestableFullGalleryPermissions) - } - - var hasGalleryAccess by remember { - mutableStateOf(hasFullGalleryPermission() || hasPartialGalleryPermission()) - } - val galleryPermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) { - hasGalleryAccess = hasFullGalleryPermission() || hasPartialGalleryPermission() - } - - var hasCameraPermission by remember { - mutableStateOf( - ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == - PackageManager.PERMISSION_GRANTED - ) - } - val cameraPermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> - hasCameraPermission = granted - if (granted) { - component.showCamera() - } - } - - Scaffold( - modifier = Modifier.fillMaxSize(), - containerColor = MaterialTheme.colorScheme.surface, - topBar = { - TopAppBar( - title = { - Column { - Text( - text = state.chatTitle.ifBlank { stringResource(R.string.story_compose_title) }, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = stringResource( - R.string.story_post_as, - state.chatTitle.ifBlank { stringResource(R.string.story_compose_title) } - ), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - navigationIcon = { - IconButton(onClick = component::dismiss) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = null) - } - }, - actions = { - IconButton(onClick = component::openMediaPicker) { - Icon(Icons.Rounded.Add, contentDescription = null) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) - }, - bottomBar = { - Surface(shadowElevation = 8.dp) { - Row( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .imePadding() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - FilledTonalButton( - onClick = component::openMediaPicker, - modifier = Modifier.weight(1f) - ) { - Icon( - imageVector = Icons.Rounded.PhotoLibrary, - contentDescription = null - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.story_change_media)) - } - - Button( - onClick = component::submitStory, - enabled = state.composerDraft.isValid && - !state.isSubmitting && - canPublishStory(state.postCapability), - modifier = Modifier.weight(1.2f) - ) { - if (state.isSubmitting) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary - ) - Spacer(modifier = Modifier.width(8.dp)) - } - Text( - if (state.isSubmitting) { - stringResource(R.string.story_posting) - } else { - stringResource(R.string.story_publish) - } - ) - } - } - } - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(scrollState) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - StoryComposerPreviewCard( - state = state, - onSelectMedia = component::openMediaPicker, - onOpenCamera = { - if (hasCameraPermission) { - component.showCamera() - } else { - cameraPermissionLauncher.launch(Manifest.permission.CAMERA) - } - } - ) - - capability?.let { presentation -> - StoryCapabilityCard(presentation = presentation) - } - - state.inlineError?.let { message -> - StoryErrorBanner(message = message) - } - - StorySettingsCard( - title = stringResource(R.string.story_settings_title), - subtitle = stringResource(R.string.story_caption_supporting) - ) { - OutlinedTextField( - value = state.composerDraft.caption, - onValueChange = component::updateCaption, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.story_caption_label)) }, - minLines = 2, - maxLines = 4 - ) - - Spacer(modifier = Modifier.height(12.dp)) - - StoryPrivacySection( - selected = when (state.composerDraft.privacy.mode) { - StoryPrivacyMode.CONTACTS -> StoryPrivacyUi.CONTACTS - StoryPrivacyMode.CLOSE_FRIENDS -> StoryPrivacyUi.CLOSE_FRIENDS - else -> StoryPrivacyUi.EVERYONE - }, - onSelect = component::updatePrivacy - ) - - Spacer(modifier = Modifier.height(12.dp)) - - StoryDurationSection( - selectedSeconds = state.composerDraft.activePeriodSeconds, - onSelect = component::updateActivePeriod - ) - - Spacer(modifier = Modifier.height(12.dp)) - - StorySwitchRow( - title = stringResource(R.string.story_keep_on_profile), - subtitle = stringResource(R.string.story_keep_on_profile_subtitle), - checked = state.composerDraft.keepOnProfile, - onCheckedChange = component::updateKeepOnProfile - ) - Spacer(modifier = Modifier.height(10.dp)) - StorySwitchRow( - title = stringResource(R.string.story_protect_content), - subtitle = stringResource(R.string.story_protect_content_subtitle), - checked = state.composerDraft.protectContent, - onCheckedChange = component::updateProtectContent - ) - } - - if (!state.composerDraft.widgetLink.isNullOrBlank()) { - StorySettingsCard( - title = stringResource(R.string.story_widget_link_title), - subtitle = stringResource(R.string.story_widget_link_subtitle) - ) { - ListItem( - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - leadingContent = { - Icon(Icons.Rounded.Link, contentDescription = null) - }, - headlineContent = { - Text(stringResource(R.string.story_widget_link_title)) - }, - supportingContent = { - Text( - text = state.composerDraft.widgetLink.orEmpty(), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - ) - } - } - } - } - - if (state.showMediaPicker) { - ModalBottomSheet( - onDismissRequest = component::dismissMediaPicker, - sheetState = androidx.compose.material3.rememberModalBottomSheetState( - skipPartiallyExpanded = true - ), - dragHandle = { BottomSheetDefaults.DragHandle() }, - containerColor = MaterialTheme.colorScheme.surface - ) { - GalleryScreen( - onMediaSelected = { uris -> - val first = uris.firstOrNull() ?: return@GalleryScreen - val path = context.copyUriToTempPath(first) ?: return@GalleryScreen - component.attachMedia(path, inferUriMediaType(first)) - }, - onDismiss = component::dismissMediaPicker, - onCameraClick = { - if (hasCameraPermission) { - component.showCamera() - } else { - cameraPermissionLauncher.launch(Manifest.permission.CAMERA) - } - }, - canSelectMedia = true, - canUseCamera = true, - canAttachFiles = false, - canCreatePoll = false, - canCreateChecklist = false, - onAttachFileClick = {}, - onCreatePollClick = {}, - onCreateChecklistClick = {}, - attachBots = emptyList(), - hasMediaAccess = hasGalleryAccess || hasFullGalleryPermission() || hasPartialGalleryPermission(), - isPartialAccess = hasPartialGalleryPermission() && !hasFullGalleryPermission(), - onPickFromOtherSources = {}, - onRequestMediaAccess = { - if (requestableGalleryPermissions.isNotEmpty()) { - galleryPermissionLauncher.launch(requestableGalleryPermissions.toTypedArray()) - } - }, - onAttachBotClick = {}, - modifier = Modifier.fillMaxHeight() - ) - } - } - - if (state.showCamera) { - CameraScreen( - onImageCaptured = { uri -> - val path = context.copyUriToTempPath(uri) - if (path != null) { - component.attachMedia(path, StoryMediaType.PHOTO) - } else { - component.dismissCamera() - } - }, - onDismiss = component::dismissCamera - ) - } -} - -@Composable -private fun StoryComposerPreviewCard( - state: StoriesHostComponent.State, - onSelectMedia: () -> Unit, - onOpenCamera: () -> Unit -) { - val context = LocalContext.current - val previewModel = rememberStoryImageModel( - context = context, - primaryPath = state.composerDraft.sourcePath.takeIf { it.isNotBlank() }, - fallbackPath = null, - minithumbnail = null + StoryComposerOverlayComponent( + state = state, + component = component ) - - Surface( - shape = RoundedCornerShape(32.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 220.dp, max = 340.dp) - .aspectRatio(0.76f) - .clip(RoundedCornerShape(22.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .clickable(onClick = onSelectMedia), - contentAlignment = Alignment.Center - ) { - if (previewModel == null) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.size(64.dp) - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Rounded.Image, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - } - Text( - text = stringResource(R.string.story_pick_media), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = stringResource(R.string.story_select_media_hint), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } else { - AsyncImage( - model = previewModel, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = listOf( - Color.Transparent, - Color.Black.copy(alpha = 0.38f) - ) - ) - ) - ) - Row( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - StoryMetadataChip( - icon = if (state.composerDraft.mediaType == StoryMediaType.VIDEO) { - Icons.Rounded.PlayArrow - } else { - Icons.Rounded.Image - }, - label = if (state.composerDraft.mediaType == StoryMediaType.VIDEO) { - stringResource(R.string.media_type_video) - } else { - stringResource(R.string.media_type_photo) - } - ) - } - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - FilledTonalButton( - onClick = onSelectMedia, - modifier = Modifier.weight(1f) - ) { - Icon(Icons.Rounded.PhotoLibrary, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.gallery_title_attachments)) - } - OutlinedButton( - onClick = onOpenCamera, - modifier = Modifier.weight(1f) - ) { - Icon(Icons.Rounded.CameraAlt, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.permission_camera_title)) - } - } - } - } } @Composable @@ -1495,54 +406,14 @@ private fun StorySettingsCard( subtitle: String?, content: @Composable () -> Unit ) { - Surface( - shape = RoundedCornerShape(30.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow - ) { - Column( - modifier = Modifier.padding(18.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - if (!subtitle.isNullOrBlank()) { - Text( - text = subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - content() - } - } + StorySettingsCardComponent(title = title, subtitle = subtitle, content = content) } @Composable private fun StoryCapabilityCard( presentation: StoryCapabilityPresentation ) { - Surface( - shape = RoundedCornerShape(28.dp), - color = if (presentation.isBlocking) { - MaterialTheme.colorScheme.errorContainer - } else { - MaterialTheme.colorScheme.secondaryContainer - } - ) { - Text( - text = presentation.message, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - style = MaterialTheme.typography.bodyMedium, - color = if (presentation.isBlocking) { - MaterialTheme.colorScheme.onErrorContainer - } else { - MaterialTheme.colorScheme.onSecondaryContainer - } - ) - } + StoryCapabilityCardComponent(presentation = presentation) } @Composable @@ -1552,103 +423,19 @@ private fun StoryStripTile( hasUnread: Boolean, onClick: () -> Unit ) { - Column( - modifier = Modifier - .width(80.dp) - .clickable(onClick = onClick), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Surface( - shape = CircleShape, - color = Color.Transparent, - border = BorderStroke( - width = if (hasUnread) 2.5.dp else 1.dp, - color = if (hasUnread) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.7f) - } - ), - modifier = Modifier.size(70.dp) - ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(4.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - ) { - if (avatarPath != null) { - AsyncImage( - model = avatarPath, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } else { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Rounded.Image, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - Text( - text = title, - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } + StoryStripTileComponent( + title = title, + avatarPath = avatarPath, + hasUnread = hasUnread, + onClick = onClick + ) } @Composable private fun AddStoryStripTile( onClick: () -> Unit ) { - Column( - modifier = Modifier - .width(80.dp) - .clickable(onClick = onClick), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.secondaryContainer, - border = BorderStroke(1.5.dp, MaterialTheme.colorScheme.secondary), - modifier = Modifier.size(70.dp) - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Rounded.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.size(28.dp) - ) - } - } - Text( - text = stringResource(R.string.story_create), - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } + AddStoryStripTileComponent(onClick = onClick) } @OptIn(ExperimentalLayoutApi::class) @@ -1657,36 +444,7 @@ private fun StoryPrivacySection( selected: StoryPrivacyUi, onSelect: (StoryPrivacyUi) -> Unit ) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - StoryPrivacyUi.entries.forEach { option -> - FilterChip( - selected = selected == option, - onClick = { onSelect(option) }, - label = { - Text( - text = when (option) { - StoryPrivacyUi.EVERYONE -> stringResource(R.string.story_privacy_everyone) - StoryPrivacyUi.CONTACTS -> stringResource(R.string.story_privacy_contacts) - StoryPrivacyUi.CLOSE_FRIENDS -> stringResource(R.string.story_privacy_close_friends) - } - ) - }, - leadingIcon = { - Icon( - imageVector = when (option) { - StoryPrivacyUi.EVERYONE -> Icons.Rounded.Public - StoryPrivacyUi.CONTACTS -> Icons.Rounded.PeopleAlt - StoryPrivacyUi.CLOSE_FRIENDS -> Icons.Rounded.Favorite - }, - contentDescription = null - ) - } - ) - } - } + StoryPrivacySectionComponent(selected = selected, onSelect = onSelect) } @OptIn(ExperimentalLayoutApi::class) @@ -1695,30 +453,7 @@ private fun StoryDurationSection( selectedSeconds: Int, onSelect: (Int) -> Unit ) { - val durations = listOf( - 6 * 60 * 60 to stringResource(R.string.story_duration_6h), - 12 * 60 * 60 to stringResource(R.string.story_duration_12h), - 24 * 60 * 60 to stringResource(R.string.story_duration_24h), - 48 * 60 * 60 to stringResource(R.string.story_duration_48h) - ) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - durations.forEach { (seconds, label) -> - FilterChip( - selected = selectedSeconds == seconds, - onClick = { onSelect(seconds) }, - label = { Text(text = label) }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.Schedule, - contentDescription = null - ) - } - ) - } - } + StoryDurationSectionComponent(selectedSeconds = selectedSeconds, onSelect = onSelect) } @Composable @@ -1728,40 +463,15 @@ private fun StorySwitchRow( checked: Boolean, onCheckedChange: (Boolean) -> Unit ) { - Surface( - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = if (checked) Icons.Rounded.Shield else Icons.Rounded.Public, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch(checked = checked, onCheckedChange = onCheckedChange) - } - } + StorySwitchRowComponent( + title = title, + subtitle = subtitle, + checked = checked, + onCheckedChange = onCheckedChange + ) } -private fun inferUriMediaType(uri: Uri): StoryMediaType { +internal fun inferUriMediaType(uri: Uri): StoryMediaType { val normalized = uri.toString().lowercase() return if ( normalized.endsWith(".mp4") || @@ -1776,155 +486,39 @@ private fun inferUriMediaType(uri: Uri): StoryMediaType { } @Composable -private fun StoryInlineVideoPlayer( - path: String, - previewModel: Any?, - isMuted: Boolean, - isPlaying: Boolean, - restartPlaybackToken: Int, - onProgress: (Float) -> Unit, - onBufferingChange: (Boolean) -> Unit, - onPlayingChange: (Boolean) -> Unit, - onCompleted: () -> Unit -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - val currentOnCompleted by rememberUpdatedState(onCompleted) - val mediaUri = remember(path) { resolveVideoUri(path) } - var completed by remember(path) { mutableStateOf(false) } - - val exoPlayer = remember(path) { - ExoPlayer.Builder(context).build().apply { - repeatMode = Player.REPEAT_MODE_OFF - volume = if (isMuted) 0f else 1f - setMediaItem(MediaItem.fromUri(mediaUri)) - prepare() - } - } - - DisposableEffect(exoPlayer, lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { - exoPlayer.pause() - } - } - val listener = object : Player.Listener { - override fun onIsPlayingChanged(isPlayingState: Boolean) { - onPlayingChange(isPlayingState) - } - - override fun onPlaybackStateChanged(playbackState: Int) { - onBufferingChange(playbackState == Player.STATE_BUFFERING) - if (playbackState == Player.STATE_ENDED && !completed) { - completed = true - onProgress(1f) - currentOnCompleted() - } - } - - override fun onPlayerError(error: PlaybackException) { - onBufferingChange(false) - onPlayingChange(false) - } - } - - lifecycleOwner.lifecycle.addObserver(observer) - exoPlayer.addListener(listener) - - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - exoPlayer.removeListener(listener) - exoPlayer.release() - } - } - - LaunchedEffect(exoPlayer, isMuted) { - exoPlayer.volume = if (isMuted) 0f else 1f - } - - LaunchedEffect(exoPlayer, isPlaying) { - if (isPlaying) { - if (exoPlayer.playbackState == Player.STATE_ENDED) { - completed = false - exoPlayer.seekTo(0) - } - exoPlayer.play() - } else { - exoPlayer.pause() - } - } - - LaunchedEffect(exoPlayer, restartPlaybackToken) { - completed = false - exoPlayer.seekTo(0) - onProgress(0f) - if (isPlaying) { - exoPlayer.play() - } - } - - LaunchedEffect(exoPlayer) { - while (true) { - val duration = exoPlayer.duration - if (duration > 0L) { - onProgress( - (exoPlayer.currentPosition.toFloat() / duration.toFloat()).coerceIn( - 0f, - 1f - ) - ) - } - delay(40) - } - } - - Box(modifier = Modifier.fillMaxSize()) { - if (previewModel != null) { - AsyncImage( - model = previewModel, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } - - AndroidView( - factory = { viewContext -> - PlayerView(viewContext).apply { - useController = false - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM - player = exoPlayer - } - }, - update = { view -> - view.player = exoPlayer - view.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM - }, - modifier = Modifier.fillMaxSize() - ) +internal fun formatStoryDurationLabel(seconds: Int): String { + return when (seconds) { + 6 * 60 * 60 -> stringResource(R.string.story_duration_6h) + 12 * 60 * 60 -> stringResource(R.string.story_duration_12h) + 24 * 60 * 60 -> stringResource(R.string.story_duration_24h) + 48 * 60 * 60 -> stringResource(R.string.story_duration_48h) + else -> "${(seconds / 3600f).roundToInt()}h" } } -private fun resolveVideoUri(path: String): Uri { - return when { - path.startsWith("content:") || - path.startsWith("file:") || - path.startsWith("http://") || - path.startsWith("https://") -> Uri.parse(path) +internal data class StoryViewerMenuItem( + val action: StoryViewerMenuAction, + val group: StoryViewerMenuGroup +) - else -> Uri.fromFile(File(path)) - } +internal enum class StoryViewerMenuAction { + DOWNLOAD, + LINKS, + EDIT, + ARCHIVE, + RESTORE, + STATISTICS, + DELETE } -private data class StoryViewerPageState( - val story: StoryModel?, - val viewerIndex: Int, - val isLoading: Boolean, - val inlineError: String? -) +internal enum class StoryViewerMenuGroup { + CONTENT, + MANAGE, + DESTRUCTIVE +} @Composable -private fun StoryTopIconButton( +internal fun StoryTopIconButton( onClick: () -> Unit, contentDescription: String, content: @Composable () -> Unit @@ -1970,7 +564,7 @@ private fun StoryViewerSystemBars() { } @Composable -private fun StoryHeaderSkeleton() { +internal fun StoryHeaderSkeleton() { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp) @@ -1989,7 +583,7 @@ private fun StoryHeaderSkeleton() { } @Composable -private fun StorySkeletonBar(modifier: Modifier = Modifier) { +internal fun StorySkeletonBar(modifier: Modifier = Modifier) { Box( modifier = modifier .clip(RoundedCornerShape(8.dp)) @@ -1999,7 +593,7 @@ private fun StorySkeletonBar(modifier: Modifier = Modifier) { } @Composable -private fun StorySkeletonPlaceholder(modifier: Modifier = Modifier) { +internal fun StorySkeletonPlaceholder(modifier: Modifier = Modifier) { val transition = rememberInfiniteTransition(label = "story_skeleton") val alpha by transition.animateFloat( initialValue = 0.32f, @@ -2018,11 +612,87 @@ private fun StorySkeletonPlaceholder(modifier: Modifier = Modifier) { ) } -private data class StoryCapabilityPresentation( +internal data class StoryCapabilityPresentation( val message: String, val isBlocking: Boolean ) +internal const val STORY_MEDIA_ASPECT_RATIO = 9f / 16f + +@Composable +internal fun storyAreaPrimaryLabel(areaType: StoryAreaTypeModel): String { + return when (areaType) { + is StoryAreaTypeModel.Link -> stringResource(R.string.story_links_title) + is StoryAreaTypeModel.Location -> areaType.label + is StoryAreaTypeModel.Venue -> areaType.title + is StoryAreaTypeModel.Message -> stringResource(R.string.story_area_message) + is StoryAreaTypeModel.UpgradedGift -> areaType.giftName.ifBlank { + stringResource(R.string.story_area_gift) + } + + is StoryAreaTypeModel.Weather -> stringResource( + R.string.story_area_weather_format, + areaType.emoji, + areaType.temperature.roundToInt() + ) + + is StoryAreaTypeModel.SuggestedReaction -> storyReactionLabel(areaType.reaction) + } +} + +@Composable +internal fun storyAreaSecondaryLabel(areaType: StoryAreaTypeModel): String? { + return when (areaType) { + is StoryAreaTypeModel.Link -> stringResource(R.string.story_area_link_hint) + is StoryAreaTypeModel.Location -> stringResource(R.string.story_area_location) + is StoryAreaTypeModel.Venue -> areaType.address?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.story_area_venue) + + is StoryAreaTypeModel.Message -> stringResource(R.string.story_area_message_hint) + is StoryAreaTypeModel.UpgradedGift -> stringResource(R.string.story_area_gift) + is StoryAreaTypeModel.Weather -> null + is StoryAreaTypeModel.SuggestedReaction -> areaType.totalCount.takeIf { it > 0 }?.toString() + } +} + +internal fun storyReactionLabel(reaction: StoryReactionModel): String { + return when { + reaction.emoji != null -> reaction.emoji.orEmpty() + reaction.customEmojiId != null -> "✨" + reaction.isPaid -> "⭐" + else -> "❤" + } +} + +internal fun formatCompactStoryCount(count: Int): String { + val absoluteCount = count.toLong().absoluteValue + if (absoluteCount < 1_000L) return count.toString() + + val formatted = when { + absoluteCount < 1_000_000L -> formatCompactStoryCountValue(absoluteCount, 1_000L, "K") + else -> formatCompactStoryCountValue(absoluteCount, 1_000_000L, "M") + } + + return if (count < 0) "-$formatted" else formatted +} + +private fun formatCompactStoryCountValue( + count: Long, + divisor: Long, + suffix: String +): String { + val whole = count / divisor + val remainder = count % divisor + val decimalStep = divisor / 10L + val decimal = if (decimalStep == 0L) 0L else remainder / decimalStep + + return if (whole >= 100L || decimal == 0L) { + "$whole$suffix" + } else { + "$whole.$decimal$suffix" + } +} + internal fun resolveStoryAutoAdvanceDurationMs(story: StoryModel): Int { val durationSeconds = story.media.durationSeconds val baseDurationMs = when { @@ -2040,12 +710,119 @@ internal fun shouldRestartCurrentStoryFromPreviousTap(progress: Float): Boolean return progress > 0.3f } -private fun resolveStoryDownloadPath(story: StoryModel?): String? { +internal fun resolveStoryDownloadPath(story: StoryModel?): String? { return story?.media?.path?.takeIf { it.isNotBlank() } ?: story?.media?.previewPath?.takeIf { it.isNotBlank() } } -private fun buildStoryPositionText(state: StoriesHostComponent.State): String { +internal fun buildStoryViewerMenuActions( + story: StoryModel?, + canManageStories: Boolean, + activeListType: StoryListType +): List { + if (story == null) return emptyList() + + return buildList { + if (resolveStoryDownloadPath(story) != null) { + add( + StoryViewerMenuItem( + action = StoryViewerMenuAction.DOWNLOAD, + group = StoryViewerMenuGroup.CONTENT + ) + ) + } + if (story.linkUrls.isNotEmpty()) { + add( + StoryViewerMenuItem( + action = StoryViewerMenuAction.LINKS, + group = StoryViewerMenuGroup.CONTENT + ) + ) + } + if (story.canBeEdited && resolveStoryEditableMediaPath(story) != null) { + add( + StoryViewerMenuItem( + action = StoryViewerMenuAction.EDIT, + group = StoryViewerMenuGroup.MANAGE + ) + ) + } + if (canManageStories) { + add( + StoryViewerMenuItem( + action = if (activeListType == StoryListType.MAIN) { + StoryViewerMenuAction.ARCHIVE + } else { + StoryViewerMenuAction.RESTORE + }, + group = StoryViewerMenuGroup.MANAGE + ) + ) + } + if (story.canGetStatistics) { + add( + StoryViewerMenuItem( + action = StoryViewerMenuAction.STATISTICS, + group = StoryViewerMenuGroup.MANAGE + ) + ) + } + if (story.canBeDeleted) { + add( + StoryViewerMenuItem( + action = StoryViewerMenuAction.DELETE, + group = StoryViewerMenuGroup.DESTRUCTIVE + ) + ) + } + } +} + +@Composable +internal fun storyViewerMenuTitle( + action: StoryViewerMenuAction +): String { + return when (action) { + StoryViewerMenuAction.DOWNLOAD -> stringResource(R.string.action_download) + StoryViewerMenuAction.LINKS -> stringResource(R.string.story_links_title) + StoryViewerMenuAction.EDIT -> stringResource(R.string.action_edit) + StoryViewerMenuAction.ARCHIVE -> stringResource(R.string.story_archive) + StoryViewerMenuAction.RESTORE -> stringResource(R.string.action_restore) + StoryViewerMenuAction.STATISTICS -> stringResource(R.string.story_statistics_title) + StoryViewerMenuAction.DELETE -> stringResource(R.string.menu_delete) + } +} + +internal fun storyViewerMenuIcon( + action: StoryViewerMenuAction +): androidx.compose.ui.graphics.vector.ImageVector { + return when (action) { + StoryViewerMenuAction.DOWNLOAD -> Icons.Rounded.Download + StoryViewerMenuAction.LINKS -> Icons.Rounded.Link + StoryViewerMenuAction.EDIT -> Icons.Rounded.Edit + StoryViewerMenuAction.ARCHIVE -> Icons.Rounded.Archive + StoryViewerMenuAction.RESTORE -> Icons.Rounded.Restore + StoryViewerMenuAction.STATISTICS -> Icons.Rounded.BarChart + StoryViewerMenuAction.DELETE -> Icons.Rounded.Delete + } +} + +@Composable +internal fun storyInteractionTypeLabel( + type: StoryInteractionTypeModel, + reaction: String? +): String { + return when (type) { + StoryInteractionTypeModel.VIEW -> reaction?.takeIf { it.isNotBlank() }?.let { + stringResource(R.string.story_interaction_view_with_reaction, it) + } ?: stringResource(R.string.story_interaction_view) + + StoryInteractionTypeModel.FORWARD -> stringResource(R.string.story_interaction_forward) + StoryInteractionTypeModel.REPOST -> stringResource(R.string.story_interaction_repost) + } +} + +internal fun buildStoryPositionText(state: StoriesHostComponent.State): String { val currentChatId = state.chatId val totalInChat = state.viewerItems.count { it.chatId == currentChatId }.coerceAtLeast(1) val currentIndexInChat = state.viewerItems @@ -2055,7 +832,7 @@ private fun buildStoryPositionText(state: StoriesHostComponent.State): String { return "$currentIndexInChat/$totalInChat" } -private fun formatStoryPostedTime(context: Context, dateSeconds: Int): String { +internal fun formatStoryPostedTime(context: Context, dateSeconds: Int): String { return DateFormat.getTimeFormat(context).format(Date(dateSeconds * 1000L)) } @@ -2067,7 +844,7 @@ private tailrec fun Context.findActivity(): Activity? { } } -private data class StoryHeaderInfoState( +internal data class StoryHeaderInfoState( val title: String, val storyId: Int?, val positionText: String, @@ -2079,7 +856,7 @@ internal fun canPublishStory(capability: StoryPostCapabilityModel?): Boolean { } @Composable -private fun StoryPostCapabilityModel?.toCapabilityPresentation(): StoryCapabilityPresentation? { +internal fun StoryPostCapabilityModel?.toCapabilityPresentation(): StoryCapabilityPresentation? { return when (this) { null -> null is StoryPostCapabilityModel.Allowed -> StoryCapabilityPresentation( @@ -2122,14 +899,4 @@ private fun StoryPostCapabilityModel?.toCapabilityPresentation(): StoryCapabilit isBlocking = true ) } -} - -private fun formatDurationLabel(totalSeconds: Int): String { - val minutes = totalSeconds / 60 - val seconds = totalSeconds % 60 - return if (minutes > 0) { - "%d:%02d".format(minutes, seconds) - } else { - "0:%02d".format(seconds) - } -} +} \ No newline at end of file diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoriesStripComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoriesStripComponents.kt new file mode 100644 index 000000000..8372f8ca2 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoriesStripComponents.kt @@ -0,0 +1,139 @@ +package org.monogram.presentation.features.stories + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.monogram.presentation.R + +@Composable +internal fun StoryStripTileComponent( + title: String, + avatarPath: String?, + hasUnread: Boolean, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .width(80.dp) + .clickable(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Surface( + shape = CircleShape, + color = Color.Transparent, + border = BorderStroke( + width = if (hasUnread) 2.5.dp else 1.dp, + color = if (hasUnread) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.7f) + } + ), + modifier = Modifier.size(70.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(4.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + if (avatarPath != null) { + AsyncImage( + model = avatarPath, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Image, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + Text( + text = title, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +internal fun AddStoryStripTileComponent( + onClick: () -> Unit +) { + Column( + modifier = Modifier + .width(80.dp) + .clickable(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + border = BorderStroke(1.5.dp, MaterialTheme.colorScheme.secondary), + modifier = Modifier.size(70.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(28.dp) + ) + } + } + Text( + text = stringResource(R.string.story_create), + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryComposerOverlayComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryComposerOverlayComponents.kt new file mode 100644 index 000000000..c701adec0 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryComposerOverlayComponents.kt @@ -0,0 +1,1625 @@ +package org.monogram.presentation.features.stories + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.VolumeOff +import androidx.compose.material.icons.automirrored.rounded.VolumeUp +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.CameraAlt +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PeopleAlt +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PhotoLibrary +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Public +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.Shield +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.media3.ui.AspectRatioFrameLayout +import coil3.compose.AsyncImage +import org.monogram.domain.models.stories.StoryComposerMediaItemModel +import org.monogram.domain.models.stories.StoryMediaType +import org.monogram.domain.models.stories.StoryPrivacyMode +import org.monogram.presentation.R +import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.core.ui.SettingsSwitchTile +import org.monogram.presentation.core.ui.SettingsTextField +import org.monogram.presentation.core.ui.SettingsTile +import org.monogram.presentation.features.camera.CameraScreen +import org.monogram.presentation.features.chats.conversation.editor.photo.PhotoEditorScreen +import org.monogram.presentation.features.chats.conversation.editor.video.VideoEditorScreen +import org.monogram.presentation.features.chats.conversation.ui.inputbar.copyUriToTempPath +import org.monogram.presentation.features.chats.conversation.ui.inputbar.declaredPermissions +import org.monogram.presentation.features.chats.conversation.ui.inputbar.hasAllPermissions +import org.monogram.presentation.features.gallery.GalleryScreen +import java.io.File + +private enum class StoryComposerStage { + COMPOSE, + PREVIEW, + PHOTO_EDITOR, + VIDEO_EDITOR +} + +private data class StoryComposerActionModel( + val icon: androidx.compose.ui.graphics.vector.ImageVector, + val title: Int, + val subtitle: Int, + val iconColor: Color, + val enabled: Boolean, + val onClick: () -> Unit +) + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +internal fun StoryComposerOverlayComponent( + state: StoriesHostComponent.State, + component: StoriesHostComponent +) { + val context = LocalContext.current + val scrollState = rememberScrollState() + val capability = state.postCapability.toCapabilityPresentation() + val isEditMode = state.composerMode == StoryComposerMode.EDIT + val hasMedia = state.composerDraft.isValid + var stage by rememberSaveable { mutableStateOf(StoryComposerStage.COMPOSE) } + val screenTitle = stringResource( + if (isEditMode) R.string.story_edit_title else R.string.story_compose_title + ) + val composerTitle = state.chatTitle.ifBlank { + stringResource( + if (isEditMode) R.string.story_edit_title else R.string.story_compose_title + ) + } + val stageSubtitle = when (stage) { + StoryComposerStage.COMPOSE -> { + stringResource( + if (isEditMode) R.string.story_edit_as else R.string.story_post_as, + composerTitle + ) + } + + StoryComposerStage.PREVIEW -> stringResource(R.string.story_preview_subtitle) + StoryComposerStage.PHOTO_EDITOR -> stringResource(R.string.story_photo_editor_title) + StoryComposerStage.VIDEO_EDITOR -> stringResource(R.string.story_video_editor_title) + } + + LaunchedEffect(hasMedia) { + if (!hasMedia && stage != StoryComposerStage.COMPOSE) { + stage = StoryComposerStage.COMPOSE + } + } + + if (stage == StoryComposerStage.PHOTO_EDITOR && hasMedia) { + PhotoEditorScreen( + imagePath = state.composerDraft.sourcePath, + onClose = { stage = StoryComposerStage.COMPOSE }, + onSave = { editedPath -> + component.attachMedia(editedPath, StoryMediaType.PHOTO) + stage = StoryComposerStage.COMPOSE + } + ) + return + } + + if (stage == StoryComposerStage.VIDEO_EDITOR && hasMedia) { + VideoEditorScreen( + videoPath = state.composerDraft.sourcePath, + onClose = { stage = StoryComposerStage.COMPOSE }, + onSave = { editedPath -> + component.attachMedia(editedPath, StoryMediaType.VIDEO) + stage = StoryComposerStage.COMPOSE + } + ) + return + } + + BackHandler(enabled = stage == StoryComposerStage.PREVIEW) { + stage = StoryComposerStage.COMPOSE + } + + val galleryPermissions = remember { + when { + android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> listOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED + ) + + android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU -> listOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO + ) + + else -> listOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + val fullGalleryPermissions = remember { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + listOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO) + } else { + listOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + val requestableGalleryPermissions = remember(context, galleryPermissions) { + val declared = context.declaredPermissions() + galleryPermissions.filter { it in declared } + } + val requestableFullGalleryPermissions = remember(context, fullGalleryPermissions) { + val declared = context.declaredPermissions() + fullGalleryPermissions.filter { it in declared } + } + + fun hasPartialGalleryPermission(): Boolean { + return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED + ) == PackageManager.PERMISSION_GRANTED + } + + fun hasFullGalleryPermission(): Boolean { + return requestableFullGalleryPermissions.isEmpty() || + context.hasAllPermissions(requestableFullGalleryPermissions) + } + + var hasGalleryAccess by remember { + mutableStateOf(hasFullGalleryPermission() || hasPartialGalleryPermission()) + } + val galleryPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { + hasGalleryAccess = hasFullGalleryPermission() || hasPartialGalleryPermission() + } + + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) + } + val cameraPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + hasCameraPermission = granted + if (granted) { + component.showCamera() + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = MaterialTheme.colorScheme.background, + topBar = { + TopAppBar( + title = { + Column { + Text( + text = screenTitle, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stageSubtitle, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + navigationIcon = { + IconButton(onClick = component::dismiss) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = null) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + scrolledContainerColor = MaterialTheme.colorScheme.background + ) + ) + }, + bottomBar = { + Surface( + color = MaterialTheme.colorScheme.background, + shadowElevation = 10.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + OutlinedButton( + onClick = { + if (stage == StoryComposerStage.PREVIEW) { + stage = StoryComposerStage.COMPOSE + } else if (hasMedia) { + stage = StoryComposerStage.PREVIEW + } else { + component.openMediaPicker() + } + }, + modifier = Modifier + .weight(1f) + .height(56.dp), + shape = RoundedCornerShape(16.dp), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 12.dp) + ) { + Icon( + imageVector = if (stage == StoryComposerStage.PREVIEW) { + Icons.Rounded.Edit + } else if (hasMedia) { + Icons.Rounded.PlayArrow + } else { + Icons.Rounded.PhotoLibrary + }, + contentDescription = null + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + stringResource( + when { + stage == StoryComposerStage.PREVIEW -> R.string.story_back_to_editor + hasMedia -> R.string.story_preview + else -> R.string.story_pick_media + } + ), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + + Button( + onClick = component::saveStory, + enabled = state.composerDraft.isValid && + !state.isSubmitting && + (isEditMode || canPublishStory(state.postCapability)), + modifier = Modifier + .weight(1f) + .height(56.dp), + shape = RoundedCornerShape(16.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp) + ) { + if (state.isSubmitting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.size(8.dp)) + } + Text( + if (state.isSubmitting) { + stringResource( + if (isEditMode) R.string.story_saving else R.string.story_posting + ) + } else { + stringResource(R.string.story_publish_short) + }, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp) + ) { + AnimatedContent( + targetState = stage, + label = "StoryComposerStage", + transitionSpec = { + (fadeIn() + slideInVertically(initialOffsetY = { it / 12 })) togetherWith + (fadeOut() + slideOutVertically(targetOffsetY = { it / 14 })) + } + ) { currentStage -> + when (currentStage) { + StoryComposerStage.COMPOSE -> { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + StorySectionHeader( + title = stringResource(R.string.story_preview_title), + subtitle = stringResource(R.string.story_preview_subtitle) + ) + StoryComposerPreviewCard( + state = state, + composerTitle = composerTitle, + subtitle = stageSubtitle, + onSelectMedia = component::openMediaPicker, + onOpenPreview = { stage = StoryComposerStage.PREVIEW }, + onSelectMediaPage = component::selectComposerMedia + ) + + StorySectionHeader( + title = stringResource(R.string.story_change_media), + subtitle = stringResource(R.string.story_select_media_hint) + ) + StoryComposerActionList( + hasMedia = hasMedia, + mediaType = state.composerDraft.mediaType, + onSelectMedia = component::openMediaPicker, + onOpenCamera = { + if (hasCameraPermission) { + component.showCamera() + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + }, + onOpenEditor = { + if (hasMedia) { + stage = when (state.composerDraft.mediaType) { + StoryMediaType.VIDEO -> StoryComposerStage.VIDEO_EDITOR + StoryMediaType.PHOTO -> StoryComposerStage.PHOTO_EDITOR + } + } + }, + onOpenPreview = { stage = StoryComposerStage.PREVIEW } + ) + + state.inlineError?.let { message -> + Spacer(modifier = Modifier.size(12.dp)) + StoryErrorBanner(message = message) + } + + StorySectionHeader( + title = stringResource(R.string.story_details_title), + subtitle = stringResource(R.string.story_caption_supporting) + ) + SettingsTextField( + value = state.composerDraft.caption, + onValueChange = component::updateCaption, + placeholder = stringResource(R.string.story_caption_label), + icon = Icons.Rounded.Edit, + position = ItemPosition.STANDALONE, + minLines = 2, + maxLines = 4 + ) + + if (!isEditMode) { + StorySectionHeader( + title = stringResource(R.string.story_privacy_label), + subtitle = null + ) + StoryChoiceSurface { + StoryPrivacySectionComponent( + selected = when (state.composerDraft.privacy.mode) { + StoryPrivacyMode.CONTACTS -> StoryPrivacyUi.CONTACTS + StoryPrivacyMode.CLOSE_FRIENDS -> StoryPrivacyUi.CLOSE_FRIENDS + else -> StoryPrivacyUi.EVERYONE + }, + onSelect = component::updatePrivacy + ) + } + + StorySectionHeader( + title = stringResource(R.string.story_duration_label), + subtitle = stringResource(R.string.story_duration_supporting) + ) + StoryChoiceSurface { + StoryDurationSectionComponent( + selectedSeconds = state.composerDraft.activePeriodSeconds, + onSelect = component::updateActivePeriod + ) + } + + StorySectionHeader( + title = stringResource(R.string.story_settings_title), + subtitle = stringResource(R.string.story_audience_timing_subtitle) + ) + SettingsSwitchTile( + icon = Icons.Rounded.Person, + title = stringResource(R.string.story_keep_on_profile), + subtitle = stringResource(R.string.story_keep_on_profile_subtitle), + checked = state.composerDraft.keepOnProfile, + iconColor = MaterialTheme.colorScheme.primary, + position = ItemPosition.TOP, + onCheckedChange = component::updateKeepOnProfile + ) + SettingsSwitchTile( + icon = Icons.Rounded.Shield, + title = stringResource(R.string.story_protect_content), + subtitle = stringResource(R.string.story_protect_content_subtitle), + checked = state.composerDraft.protectContent, + iconColor = MaterialTheme.colorScheme.tertiary, + position = ItemPosition.BOTTOM, + onCheckedChange = component::updateProtectContent + ) + } + + if (!state.composerDraft.widgetLink.isNullOrBlank()) { + StorySectionHeader( + title = stringResource(R.string.story_widget_link_title), + subtitle = stringResource(R.string.story_widget_link_subtitle) + ) + StoryWidgetLinkRow(link = state.composerDraft.widgetLink.orEmpty()) + } + + capability?.takeIf { !isEditMode }?.let { presentation -> + StorySectionHeader( + title = stringResource(R.string.story_publish), + subtitle = null + ) + StoryCapabilityFooter(presentation = presentation) + } + + Spacer(modifier = Modifier.size(16.dp)) + } + } + + StoryComposerStage.PREVIEW -> { + StoryComposerPreviewStage( + state = state, + onSelectMediaPage = component::selectComposerMedia, + onOpenEditor = { + if (hasMedia) { + stage = when (state.composerDraft.mediaType) { + StoryMediaType.VIDEO -> StoryComposerStage.VIDEO_EDITOR + StoryMediaType.PHOTO -> StoryComposerStage.PHOTO_EDITOR + } + } + }, + modifier = Modifier.fillMaxSize() + ) + } + + StoryComposerStage.PHOTO_EDITOR, + StoryComposerStage.VIDEO_EDITOR -> Unit + } + } + } + } + + if (state.showMediaPicker) { + ModalBottomSheet( + onDismissRequest = component::dismissMediaPicker, + sheetState = androidx.compose.material3.rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), + dragHandle = { BottomSheetDefaults.DragHandle() }, + containerColor = MaterialTheme.colorScheme.surface + ) { + GalleryScreen( + onMediaSelected = { uris -> + val items = uris.mapNotNull { uri -> + val path = context.copyUriToTempPath(uri) ?: return@mapNotNull null + StoryComposerMediaItemModel( + sourcePath = path, + mediaType = inferPickedStoryMediaType(context, uri, path) + ) + } + component.attachMedia(items) + }, + onDismiss = component::dismissMediaPicker, + onCameraClick = { + if (hasCameraPermission) { + component.showCamera() + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + }, + canSelectMedia = true, + canUseCamera = true, + canAttachFiles = false, + canCreatePoll = false, + canCreateChecklist = false, + onAttachFileClick = {}, + onCreatePollClick = {}, + onCreateChecklistClick = {}, + attachBots = emptyList(), + hasMediaAccess = hasGalleryAccess || hasFullGalleryPermission() || hasPartialGalleryPermission(), + isPartialAccess = hasPartialGalleryPermission() && !hasFullGalleryPermission(), + onPickFromOtherSources = {}, + onRequestMediaAccess = { + if (requestableGalleryPermissions.isNotEmpty()) { + galleryPermissionLauncher.launch(requestableGalleryPermissions.toTypedArray()) + } + }, + onAttachBotClick = {}, + modifier = Modifier.fillMaxHeight() + ) + } + } + + if (state.showCamera) { + CameraScreen( + onImageCaptured = { uri -> + val path = context.copyUriToTempPath(uri) + if (path != null) { + component.attachMedia(path, StoryMediaType.PHOTO) + } else { + component.dismissCamera() + } + }, + onDismiss = component::dismissCamera + ) + } +} + +@Composable +private fun StorySectionHeader( + title: String, + subtitle: String? +) { + Column( + modifier = Modifier.padding(start = 12.dp, top = 16.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + if (title.isNotBlank()) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun StoryChoiceSurface( + content: @Composable () -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(24.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + content() + } + } +} + +@Composable +private fun StoryWidgetLinkRow(link: String) { + SettingsTile( + icon = Icons.Rounded.Link, + title = stringResource(R.string.story_widget_link_title), + subtitle = link, + iconColor = MaterialTheme.colorScheme.primary, + position = ItemPosition.STANDALONE, + onClick = {} + ) +} + +@Composable +private fun StoryComposerIdentityAvatar( + title: String, + avatarPath: String? +) { + Surface( + modifier = Modifier.size(44.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer + ) { + if (!avatarPath.isNullOrBlank()) { + AsyncImage( + model = avatarPath, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = title.trim().take(1).ifBlank { "S" }.uppercase(), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } +} + +@Composable +private fun StoryComposerActionList( + hasMedia: Boolean, + mediaType: StoryMediaType, + onSelectMedia: () -> Unit, + onOpenCamera: () -> Unit, + onOpenEditor: () -> Unit, + onOpenPreview: () -> Unit +) { + val items = buildList { + add( + StoryComposerActionModel( + icon = Icons.Rounded.PhotoLibrary, + title = if (hasMedia) { + R.string.story_change_media + } else { + R.string.gallery_title_attachments + }, + subtitle = if (hasMedia) { + R.string.story_pick_media + } else { + R.string.story_select_media_hint + }, + iconColor = MaterialTheme.colorScheme.primary, + enabled = true, + onClick = onSelectMedia + ) + ) + add( + StoryComposerActionModel( + icon = Icons.Rounded.CameraAlt, + title = R.string.permission_camera_title, + subtitle = R.string.story_pick_media, + iconColor = MaterialTheme.colorScheme.tertiary, + enabled = true, + onClick = onOpenCamera + ) + ) + if (hasMedia) { + add( + StoryComposerActionModel( + icon = Icons.Rounded.Edit, + title = if (mediaType == StoryMediaType.VIDEO) { + R.string.story_open_video_editor + } else { + R.string.story_open_photo_editor + }, + subtitle = R.string.story_back_to_editor, + iconColor = MaterialTheme.colorScheme.secondary, + enabled = true, + onClick = onOpenEditor + ) + ) + add( + StoryComposerActionModel( + icon = Icons.Rounded.PlayArrow, + title = R.string.story_preview, + subtitle = R.string.story_preview_subtitle, + iconColor = MaterialTheme.colorScheme.primary, + enabled = true, + onClick = onOpenPreview + ) + ) + } + } + + items.forEachIndexed { index, item -> + val position = when { + items.size == 1 -> ItemPosition.STANDALONE + index == 0 -> ItemPosition.TOP + index == items.lastIndex -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + } + SettingsTile( + icon = item.icon, + title = stringResource(item.title), + subtitle = stringResource(item.subtitle), + iconColor = item.iconColor, + position = position, + enabled = item.enabled, + onClick = item.onClick + ) + } +} + +@Composable +private fun StoryCapabilityFooter( + presentation: StoryCapabilityPresentation +) { + Surface( + shape = RoundedCornerShape(24.dp), + color = if (presentation.isBlocking) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.secondaryContainer + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = CircleShape, + color = if (presentation.isBlocking) { + MaterialTheme.colorScheme.error.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } + ) { + Box( + modifier = Modifier + .size(40.dp) + .padding(10.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (presentation.isBlocking) { + Icons.Rounded.Schedule + } else { + Icons.Rounded.Add + }, + contentDescription = null, + tint = if (presentation.isBlocking) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + ) + } + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = stringResource(R.string.story_publish), + style = MaterialTheme.typography.titleSmall, + color = if (presentation.isBlocking) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onSecondaryContainer + } + ) + Text( + text = presentation.message, + style = MaterialTheme.typography.bodyMedium, + color = if (presentation.isBlocking) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onSecondaryContainer + } + ) + } + } + } +} + +@Composable +private fun StoryPreviewDetailsPanel( + state: StoriesHostComponent.State, + isVideoMuted: Boolean, + isVideoPaused: Boolean, + onTogglePlay: () -> Unit, + onToggleMute: () -> Unit, + onOpenEditor: () -> Unit +) { + val hasVideoControls = state.composerDraft.mediaType == StoryMediaType.VIDEO + val rows = buildList { + add( + StoryComposerActionModel( + icon = Icons.Rounded.Edit, + title = if (state.composerDraft.mediaType == StoryMediaType.VIDEO) { + R.string.story_open_video_editor + } else { + R.string.story_open_photo_editor + }, + subtitle = R.string.story_back_to_editor, + iconColor = MaterialTheme.colorScheme.primary, + enabled = state.composerDraft.isValid, + onClick = onOpenEditor + ) + ) + add( + StoryComposerActionModel( + icon = when (state.composerDraft.privacy.mode) { + StoryPrivacyMode.CONTACTS -> Icons.Rounded.PeopleAlt + StoryPrivacyMode.CLOSE_FRIENDS -> Icons.Rounded.Favorite + else -> Icons.Rounded.Public + }, + title = R.string.story_privacy_label, + subtitle = 0, + iconColor = MaterialTheme.colorScheme.secondary, + enabled = true, + onClick = {} + ) + ) + add( + StoryComposerActionModel( + icon = Icons.Rounded.Schedule, + title = R.string.story_duration_label, + subtitle = 0, + iconColor = MaterialTheme.colorScheme.tertiary, + enabled = true, + onClick = {} + ) + ) + if (!state.composerDraft.widgetLink.isNullOrBlank()) { + add( + StoryComposerActionModel( + icon = Icons.Rounded.Link, + title = R.string.story_widget_link_title, + subtitle = 0, + iconColor = MaterialTheme.colorScheme.primary, + enabled = true, + onClick = {} + ) + ) + } + } + val totalItems = rows.size + if (hasVideoControls) 2 else 0 + + fun positionForIndex(index: Int): ItemPosition { + return when { + totalItems <= 1 -> ItemPosition.STANDALONE + index == 0 -> ItemPosition.TOP + index == totalItems - 1 -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + } + } + + Column { + rows.forEachIndexed { index, item -> + val subtitleText = when (index) { + 1 -> when (state.composerDraft.privacy.mode) { + StoryPrivacyMode.CONTACTS -> stringResource(R.string.story_privacy_contacts) + StoryPrivacyMode.CLOSE_FRIENDS -> stringResource(R.string.story_privacy_close_friends) + else -> stringResource(R.string.story_privacy_everyone) + } + + 2 -> formatStoryDurationLabel(state.composerDraft.activePeriodSeconds) + 3 -> state.composerDraft.widgetLink.orEmpty() + .takeIf { item.icon == Icons.Rounded.Link } + + else -> if (item.subtitle != 0) stringResource(item.subtitle) else null + } + SettingsTile( + icon = item.icon, + title = stringResource(item.title), + subtitle = subtitleText, + iconColor = item.iconColor, + position = positionForIndex(index), + enabled = item.enabled, + onClick = item.onClick + ) + } + + if (hasVideoControls) { + SettingsSwitchTile( + icon = if (isVideoPaused) Icons.Rounded.PlayArrow else Icons.Rounded.Pause, + title = stringResource(if (isVideoPaused) R.string.story_play else R.string.story_pause), + subtitle = null, + checked = !isVideoPaused, + iconColor = MaterialTheme.colorScheme.primary, + position = positionForIndex(rows.size), + onCheckedChange = { onTogglePlay() } + ) + SettingsSwitchTile( + icon = if (isVideoMuted) { + Icons.AutoMirrored.Rounded.VolumeOff + } else { + Icons.AutoMirrored.Rounded.VolumeUp + }, + title = stringResource(if (isVideoMuted) R.string.story_audio_unmute else R.string.story_audio_mute), + subtitle = null, + checked = !isVideoMuted, + iconColor = MaterialTheme.colorScheme.tertiary, + position = positionForIndex(rows.size + 1), + onCheckedChange = { onToggleMute() } + ) + } + } +} + +@Composable +private fun StoryComposerPreviewCard( + state: StoriesHostComponent.State, + composerTitle: String, + subtitle: String, + onSelectMedia: () -> Unit, + onOpenPreview: () -> Unit, + onSelectMediaPage: (Int) -> Unit +) { + val mediaItems = state.composerDraft.mediaItems + val hasMedia = mediaItems.isNotEmpty() + + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainer + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StoryComposerIdentityAvatar( + title = composerTitle, + avatarPath = state.chatAvatarPath + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = composerTitle, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.size(1.dp)) + } + + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + val maxPreviewHeight = 360.dp + val maxPreviewWidth = maxPreviewHeight * STORY_MEDIA_ASPECT_RATIO + val previewWidth = if (maxWidth < maxPreviewWidth) maxWidth else maxPreviewWidth + + if (!hasMedia) { + Box( + modifier = Modifier + .width(previewWidth) + .aspectRatio(STORY_MEDIA_ASPECT_RATIO) + .clip(RoundedCornerShape(22.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .clickable(onClick = onSelectMedia), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(64.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Image, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + Text( + text = stringResource(R.string.story_pick_media), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(R.string.story_select_media_hint), + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + } + } else { + StoryComposerMediaPager( + mediaItems = mediaItems, + selectedIndex = state.composerDraft.selectedMediaIndex, + caption = state.composerDraft.caption, + onPageChanged = onSelectMediaPage, + modifier = Modifier + .width(previewWidth) + .aspectRatio(STORY_MEDIA_ASPECT_RATIO), + onClick = onOpenPreview + ) + } + } + + if (hasMedia) { + StoryComposerPagerFooter( + mediaItems = mediaItems, + selectedIndex = state.composerDraft.selectedMediaIndex + ) + } + } + } +} + +@Composable +private fun StoryComposerPreviewStage( + state: StoriesHostComponent.State, + onSelectMediaPage: (Int) -> Unit, + onOpenEditor: () -> Unit, + modifier: Modifier = Modifier +) { + val mediaItems = state.composerDraft.mediaItems + var isVideoMuted by rememberSaveable(state.composerDraft.sourcePath) { mutableStateOf(true) } + var isVideoPaused by rememberSaveable(state.composerDraft.sourcePath) { mutableStateOf(false) } + var isVideoBuffering by rememberSaveable(state.composerDraft.sourcePath) { mutableStateOf(false) } + var restartPlaybackToken by rememberSaveable(state.composerDraft.sourcePath) { mutableStateOf(0) } + + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + val maxPreviewHeight = 560.dp + val maxPreviewWidth = maxPreviewHeight * STORY_MEDIA_ASPECT_RATIO + val previewWidth = if (maxWidth < maxPreviewWidth) maxWidth else maxPreviewWidth + + Surface( + modifier = Modifier + .width(previewWidth) + .aspectRatio(STORY_MEDIA_ASPECT_RATIO), + shape = RoundedCornerShape(28.dp), + color = Color.Black + ) { + StoryComposerPreviewPager( + mediaItems = mediaItems, + selectedIndex = state.composerDraft.selectedMediaIndex, + caption = state.composerDraft.caption, + isVideoMuted = isVideoMuted, + isVideoPaused = isVideoPaused, + restartPlaybackToken = restartPlaybackToken, + isVideoBuffering = isVideoBuffering, + onPageChanged = onSelectMediaPage, + onBufferingChange = { isVideoBuffering = it }, + onPlayingChange = { playing -> isVideoPaused = !playing }, + onCompleted = { + isVideoPaused = true + restartPlaybackToken += 1 + } + ) + } + } + + if (mediaItems.isNotEmpty()) { + StoryComposerPagerFooter( + mediaItems = mediaItems, + selectedIndex = state.composerDraft.selectedMediaIndex + ) + } + + StorySectionHeader( + title = stringResource(R.string.story_settings_title), + subtitle = stringResource(R.string.story_preview_subtitle) + ) + StoryPreviewDetailsPanel( + state = state, + isVideoMuted = isVideoMuted, + isVideoPaused = isVideoPaused, + onTogglePlay = { isVideoPaused = !isVideoPaused }, + onToggleMute = { isVideoMuted = !isVideoMuted }, + onOpenEditor = onOpenEditor + ) + } +} + +@Composable +private fun StoryComposerMediaPager( + mediaItems: List, + selectedIndex: Int, + caption: String, + onPageChanged: (Int) -> Unit, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + val pagerState = rememberPagerState( + initialPage = selectedIndex.coerceIn(0, mediaItems.lastIndex), + pageCount = { mediaItems.size } + ) + + LaunchedEffect(mediaItems.size, selectedIndex) { + val targetPage = selectedIndex.coerceIn(0, mediaItems.lastIndex) + if (pagerState.currentPage != targetPage) { + pagerState.scrollToPage(targetPage) + } + } + + LaunchedEffect(pagerState.currentPage, mediaItems.size) { + if (mediaItems.isEmpty()) return@LaunchedEffect + val currentPage = pagerState.currentPage.coerceIn(0, mediaItems.lastIndex) + if (currentPage != selectedIndex) { + onPageChanged(currentPage) + } + } + + HorizontalPager( + state = pagerState, + modifier = modifier + .clip(RoundedCornerShape(22.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + userScrollEnabled = mediaItems.size > 1 + ) { page -> + val mediaItem = mediaItems[page] + StoryComposerMediaPage( + mediaItem = mediaItem, + caption = caption, + isActive = page == pagerState.currentPage, + onClick = onClick + ) + } +} + +@Composable +private fun StoryComposerMediaPage( + mediaItem: StoryComposerMediaItemModel, + caption: String, + isActive: Boolean, + onClick: (() -> Unit)? = null +) { + val context = LocalContext.current + val previewModel = rememberStoryImageModel( + context = context, + primaryPath = mediaItem.sourcePath.takeIf { it.isNotBlank() }, + fallbackPath = null, + minithumbnail = null + ) + var isVideoBuffering by rememberSaveable(mediaItem.sourcePath) { mutableStateOf(false) } + var restartPlaybackToken by rememberSaveable(mediaItem.sourcePath) { mutableStateOf(0) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .then( + if (onClick != null) { + Modifier.clickable(onClick = onClick) + } else { + Modifier + } + ) + ) { + when { + mediaItem.sourcePath.isBlank() -> { + StoryUnavailablePlaceholder(stringResource(R.string.story_pick_media)) + } + + mediaItem.mediaType == StoryMediaType.VIDEO && File(mediaItem.sourcePath).exists() -> { + StoryInlineVideoPlayer( + path = mediaItem.sourcePath, + previewModel = previewModel, + previewContentScale = ContentScale.Crop, + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + isMuted = true, + isPlaying = isActive, + restartPlaybackToken = restartPlaybackToken, + onProgress = {}, + onBufferingChange = { isVideoBuffering = it }, + onPlayingChange = {}, + onCompleted = { restartPlaybackToken += 1 } + ) + } + + previewModel != null -> { + AsyncImage( + model = previewModel, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.38f) + ) + ) + ) + ) + + if (caption.isNotBlank()) { + Surface( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(12.dp), + shape = RoundedCornerShape(18.dp), + color = Color.Black.copy(alpha = 0.38f) + ) { + Text( + text = caption, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + color = Color.White + ) + } + } + + if (mediaItem.mediaType == StoryMediaType.VIDEO) { + StoryComposerPreviewBadge( + text = stringResource(R.string.story_media_video), + modifier = Modifier + .align(Alignment.TopEnd) + .padding(12.dp) + ) + } + + androidx.compose.animation.AnimatedVisibility( + visible = isVideoBuffering, + modifier = Modifier.align(Alignment.Center) + ) { + Surface( + shape = CircleShape, + color = Color.Black.copy(alpha = 0.42f) + ) { + CircularProgressIndicator( + modifier = Modifier + .padding(16.dp) + .size(24.dp), + color = Color.White, + strokeWidth = 2.5.dp + ) + } + } + } +} + +@Composable +private fun StoryComposerPreviewPager( + mediaItems: List, + selectedIndex: Int, + caption: String, + isVideoMuted: Boolean, + isVideoPaused: Boolean, + restartPlaybackToken: Int, + isVideoBuffering: Boolean, + onPageChanged: (Int) -> Unit, + onBufferingChange: (Boolean) -> Unit, + onPlayingChange: (Boolean) -> Unit, + onCompleted: () -> Unit +) { + if (mediaItems.isEmpty()) { + StoryUnavailablePlaceholder(stringResource(R.string.story_pick_media)) + return + } + val context = LocalContext.current + + val pagerState = rememberPagerState( + initialPage = selectedIndex.coerceIn(0, mediaItems.lastIndex), + pageCount = { mediaItems.size } + ) + + LaunchedEffect(mediaItems.size, selectedIndex) { + val targetPage = selectedIndex.coerceIn(0, mediaItems.lastIndex) + if (pagerState.currentPage != targetPage) { + pagerState.scrollToPage(targetPage) + } + } + + LaunchedEffect(pagerState.currentPage, mediaItems.size) { + val currentPage = pagerState.currentPage.coerceIn(0, mediaItems.lastIndex) + if (currentPage != selectedIndex) { + onPageChanged(currentPage) + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + userScrollEnabled = mediaItems.size > 1 + ) { page -> + val mediaItem = mediaItems[page] + val pagePreviewModel = rememberStoryImageModel( + context = context, + primaryPath = mediaItem.sourcePath.takeIf { it.isNotBlank() }, + fallbackPath = null, + minithumbnail = null + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + when { + mediaItem.sourcePath.isBlank() -> { + StoryUnavailablePlaceholder(stringResource(R.string.story_pick_media)) + } + + mediaItem.mediaType == StoryMediaType.VIDEO && File(mediaItem.sourcePath).exists() -> { + StoryInlineVideoPlayer( + path = mediaItem.sourcePath, + previewModel = pagePreviewModel, + previewContentScale = ContentScale.Crop, + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + isMuted = isVideoMuted, + isPlaying = !isVideoPaused && page == pagerState.currentPage, + restartPlaybackToken = restartPlaybackToken, + onProgress = {}, + onBufferingChange = { + if (page == pagerState.currentPage) { + onBufferingChange(it) + } + }, + onPlayingChange = { + if (page == pagerState.currentPage) { + onPlayingChange(it) + } + }, + onCompleted = { + if (page == pagerState.currentPage) { + onCompleted() + } + } + ) + } + + pagePreviewModel != null -> { + AsyncImage( + model = pagePreviewModel, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.18f), + Color.Transparent, + Color.Black.copy(alpha = 0.58f) + ) + ) + ) + ) + + if (caption.isNotBlank()) { + Surface( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(14.dp), + shape = RoundedCornerShape(20.dp), + color = Color.Black.copy(alpha = 0.42f) + ) { + Text( + text = caption, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = Color.White + ) + } + } + + if (mediaItem.mediaType == StoryMediaType.VIDEO) { + StoryComposerPreviewBadge( + text = stringResource(R.string.story_media_video), + modifier = Modifier + .align(Alignment.TopEnd) + .padding(14.dp) + ) + } + + androidx.compose.animation.AnimatedVisibility( + visible = isVideoBuffering && page == pagerState.currentPage, + modifier = Modifier.align(Alignment.Center) + ) { + Surface( + shape = CircleShape, + color = Color.Black.copy(alpha = 0.42f) + ) { + CircularProgressIndicator( + modifier = Modifier + .padding(18.dp) + .size(28.dp), + color = Color.White, + strokeWidth = 2.5.dp + ) + } + } + } + } +} + +@Composable +private fun StoryComposerPagerFooter( + mediaItems: List, + selectedIndex: Int +) { + val currentMedia = mediaItems.getOrNull(selectedIndex) ?: mediaItems.firstOrNull() ?: return + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StoryComposerPreviewBadge( + text = stringResource( + if (currentMedia.mediaType == StoryMediaType.VIDEO) { + R.string.story_media_video + } else { + R.string.story_media_photo + } + ) + ) + if (mediaItems.size > 1) { + StoryComposerPreviewBadge( + text = stringResource( + R.string.story_viewer_item_position, + selectedIndex + 1, + mediaItems.size + ) + ) + } + } + + if (mediaItems.size > 1) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + mediaItems.forEachIndexed { index, _ -> + Box( + modifier = Modifier + .size(if (index == selectedIndex) 18.dp else 6.dp, 6.dp) + .clip(CircleShape) + .background( + if (index == selectedIndex) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + } + ) + ) + } + } + } + } +} + +@Composable +private fun StoryComposerPreviewBadge( + text: String, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(999.dp), + color = Color.Black.copy(alpha = 0.32f) + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + color = Color.White + ) + } +} + +private fun inferPickedStoryMediaType( + context: Context, + uri: Uri, + resolvedPath: String +): StoryMediaType { + val mimeType = context.contentResolver.getType(uri).orEmpty().lowercase() + if (mimeType.startsWith("video/")) { + return StoryMediaType.VIDEO + } + if (mimeType.startsWith("image/")) { + return StoryMediaType.PHOTO + } + val pathType = inferUriMediaType(Uri.parse(resolvedPath)) + if (pathType == StoryMediaType.VIDEO) { + return pathType + } + return inferUriMediaType(uri) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryComposerSettingsComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryComposerSettingsComponents.kt new file mode 100644 index 000000000..b82335203 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryComposerSettingsComponents.kt @@ -0,0 +1,256 @@ +package org.monogram.presentation.features.stories + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.PeopleAlt +import androidx.compose.material.icons.rounded.Public +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.Shield +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.monogram.presentation.R + +@Composable +internal fun StorySettingsCardComponent( + title: String, + subtitle: String?, + content: @Composable () -> Unit +) { + Surface( + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f) + ) + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + content() + } + } +} + +@Composable +internal fun StoryCapabilityCardComponent( + presentation: StoryCapabilityPresentation +) { + val containerColor = if (presentation.isBlocking) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.secondaryContainer + } + val contentColor = if (presentation.isBlocking) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onSecondaryContainer + } + val borderColor = if (presentation.isBlocking) { + MaterialTheme.colorScheme.error.copy(alpha = 0.18f) + } else { + MaterialTheme.colorScheme.secondary.copy(alpha = 0.18f) + } + + Surface( + shape = RoundedCornerShape(20.dp), + color = containerColor, + border = BorderStroke(width = 1.dp, color = borderColor) + ) { + Text( + text = presentation.message, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = contentColor + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun StoryPrivacySectionComponent( + selected: StoryPrivacyUi, + onSelect: (StoryPrivacyUi) -> Unit +) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + maxItemsInEachRow = 2 + ) { + StoryPrivacyUi.entries.forEach { option -> + StoryCompactChoiceButton( + selected = selected == option, + onClick = { onSelect(option) }, + label = when (option) { + StoryPrivacyUi.EVERYONE -> stringResource(R.string.story_privacy_everyone) + StoryPrivacyUi.CONTACTS -> stringResource(R.string.story_privacy_contacts) + StoryPrivacyUi.CLOSE_FRIENDS -> stringResource(R.string.story_privacy_close_friends) + }, + icon = when (option) { + StoryPrivacyUi.EVERYONE -> Icons.Rounded.Public + StoryPrivacyUi.CONTACTS -> Icons.Rounded.PeopleAlt + StoryPrivacyUi.CLOSE_FRIENDS -> Icons.Rounded.Favorite + } + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun StoryDurationSectionComponent( + selectedSeconds: Int, + onSelect: (Int) -> Unit +) { + val durations = listOf( + 6 * 60 * 60 to stringResource(R.string.story_duration_6h), + 12 * 60 * 60 to stringResource(R.string.story_duration_12h), + 24 * 60 * 60 to stringResource(R.string.story_duration_24h), + 48 * 60 * 60 to stringResource(R.string.story_duration_48h) + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + maxItemsInEachRow = 2 + ) { + durations.forEach { (seconds, label) -> + StoryCompactChoiceButton( + selected = selectedSeconds == seconds, + onClick = { onSelect(seconds) }, + label = label, + icon = Icons.Rounded.Schedule + ) + } + } +} + +@Composable +private fun StoryCompactChoiceButton( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val containerColor = if (selected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + val iconColor = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Surface( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + color = containerColor + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconColor + ) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + ) + } + } + } +} + +@Composable +internal fun StorySwitchRowComponent( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Surface( + onClick = { onCheckedChange(!checked) }, + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = if (checked) Icons.Rounded.Shield else Icons.Rounded.Public, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt new file mode 100644 index 000000000..1432493cf --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt @@ -0,0 +1,695 @@ +package org.monogram.presentation.features.stories + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.VolumeOff +import androidx.compose.material.icons.automirrored.rounded.VolumeUp +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PeopleAlt +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.monogram.domain.models.stories.StoryAreaTypeModel +import org.monogram.domain.models.stories.StoryInteractionActorType +import org.monogram.domain.models.stories.StoryInteractionPageModel +import org.monogram.domain.models.stories.StoryListType +import org.monogram.domain.models.stories.StoryModel +import org.monogram.presentation.R +import org.monogram.presentation.features.stickers.ui.menu.ActionMenuPopup +import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow +import org.monogram.presentation.features.stickers.ui.menu.MenuToggleRow + +@Composable +internal fun StoryViewerChromeComponent( + state: StoriesHostComponent.State, + story: StoryModel?, + progress: Float, + isVideo: Boolean, + isVideoPaused: Boolean, + isVideoMuted: Boolean, + isVideoBuffering: Boolean, + isVideoPlaying: Boolean, + isMediaScaledToFill: Boolean, + onBack: () -> Unit, + onPauseToggle: () -> Unit, + onMuteToggle: () -> Unit, + onReactionClick: () -> Unit, + onMediaScaleToggle: (Boolean) -> Unit, + onLinks: () -> Unit, + onEdit: () -> Unit, + onArchive: () -> Unit, + onRestore: () -> Unit, + onStatistics: () -> Unit, + onDelete: () -> Unit, + onDownload: () -> Unit +) { + var showMenu by remember(story?.id, state.activeListType, state.canManageStories) { + mutableStateOf(false) + } + val currentChatId = state.chatId + val currentChatItems = remember(state.viewerItems, currentChatId) { + state.viewerItems.filter { it.chatId == currentChatId } + } + val currentChatIndex = remember(state.viewerItems, currentChatId, state.viewerIndex) { + val currentItem = state.viewerItems.getOrNull(state.viewerIndex) + if (currentItem == null || currentChatId == null) { + 0 + } else { + currentChatItems.indexOfFirst { + it.chatId == currentItem.chatId && it.storyId == currentItem.storyId + }.coerceAtLeast(0) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + StoryViewerProgressRow( + total = currentChatItems.size, + currentIndex = currentChatIndex, + currentProgress = progress + ) + StoryViewerHeader( + state = state, + onBack = onBack, + showMoreButton = story != null, + onMore = { showMenu = true } + ) + Box(modifier = Modifier.fillMaxWidth()) { + StoryViewerActionsPopup( + visible = showMenu, + story = story, + canManageStories = state.canManageStories, + activeListType = state.activeListType, + isMediaScaledToFill = isMediaScaledToFill, + onDismiss = { showMenu = false }, + onMediaScaleToggle = onMediaScaleToggle, + onDownload = onDownload, + onLinks = onLinks, + onEdit = onEdit, + onArchive = onArchive, + onRestore = onRestore, + onStatistics = onStatistics, + onDelete = onDelete + ) + + Column( + modifier = Modifier.align(Alignment.TopEnd), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + AnimatedVisibility(visible = isVideo) { + StoryTopIconButton( + onClick = onPauseToggle, + contentDescription = if (isVideoPaused || !isVideoPlaying) { + stringResource(R.string.action_play) + } else { + stringResource(R.string.action_pause) + } + ) { + if (isVideoBuffering) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.5.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Icon( + imageVector = if (isVideoPaused || !isVideoPlaying) { + Icons.Rounded.PlayArrow + } else { + Icons.Rounded.Pause + }, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + } + + AnimatedVisibility(visible = isVideo) { + StoryTopIconButton( + onClick = onMuteToggle, + contentDescription = stringResource( + if (isVideoMuted) { + R.string.story_audio_unmute + } else { + R.string.story_audio_mute + } + ) + ) { + Icon( + imageVector = if (isVideoMuted) { + Icons.AutoMirrored.Rounded.VolumeUp + } else { + Icons.AutoMirrored.Rounded.VolumeOff + }, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + + AnimatedVisibility( + visible = story?.canGetInteractions == true || story?.areas?.any { + it.type is StoryAreaTypeModel.SuggestedReaction + } == true + ) { + StoryTopIconButton( + onClick = onReactionClick, + contentDescription = stringResource(R.string.story_reactions_button) + ) { + Icon( + imageVector = Icons.Rounded.Favorite, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + AnimatedVisibility(visible = state.inlineError != null) { + StoryErrorBanner(message = state.inlineError.orEmpty()) + } + + AnimatedVisibility(visible = !story?.caption.isNullOrBlank()) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = Color.Black.copy(alpha = 0.38f) + ) { + Text( + text = story?.caption.orEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary + ) + } + } + } + } +} + +@Composable +private fun StoryViewerHeader( + state: StoriesHostComponent.State, + onBack: () -> Unit, + showMoreButton: Boolean, + onMore: () -> Unit +) { + val context = LocalContext.current + val showSkeleton = state.chatTitle.isBlank() + val selectedItem = state.viewerItems.getOrNull(state.viewerIndex) + val headerInfo = remember( + state.chatId, + state.chatTitle, + selectedItem?.storyId, + selectedItem?.date, + state.viewerIndex, + state.viewerItems + ) { + StoryHeaderInfoState( + title = state.chatTitle, + storyId = selectedItem?.storyId, + positionText = buildStoryPositionText(state), + postedAt = selectedItem?.date?.let { formatStoryPostedTime(context, it) }.orEmpty() + ) + } + + Surface( + shape = RoundedCornerShape(22.dp), + color = Color.Black.copy(alpha = 0.30f) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Surface( + shape = CircleShape, + color = if (showSkeleton) Color.White.copy(alpha = 0.16f) else Color.White.copy( + alpha = 0.12f + ), + modifier = Modifier.size(38.dp) + ) { + if (showSkeleton) { + StorySkeletonPlaceholder() + } else if (state.chatAvatarPath != null) { + AsyncImage( + model = state.chatAvatarPath, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Image, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.9f) + ) + } + } + } + + Box(modifier = Modifier.weight(1f)) { + AnimatedContent( + targetState = headerInfo, + transitionSpec = { + (fadeIn() + slideInVertically { it / 4 }) togetherWith + (fadeOut() + slideOutVertically { -it / 4 }) + }, + label = "story_header_info" + ) { currentInfo -> + if (showSkeleton) { + StoryHeaderSkeleton() + } else { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = currentInfo.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = buildString { + append(currentInfo.positionText) + if (currentInfo.postedAt.isNotBlank()) { + append(" • ") + append(currentInfo.postedAt) + } + }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.72f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + + if (showMoreButton) { + IconButton(onClick = onMore) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.menu_more), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + } +} + +@Composable +private fun StoryViewerActionsPopup( + visible: Boolean, + story: StoryModel?, + canManageStories: Boolean, + activeListType: StoryListType, + isMediaScaledToFill: Boolean, + onDismiss: () -> Unit, + onMediaScaleToggle: (Boolean) -> Unit, + onDownload: () -> Unit, + onLinks: () -> Unit, + onEdit: () -> Unit, + onArchive: () -> Unit, + onRestore: () -> Unit, + onStatistics: () -> Unit, + onDelete: () -> Unit +) { + if (story == null) return + + val menuActions = remember(story, canManageStories, activeListType) { + buildStoryViewerMenuActions( + story = story, + canManageStories = canManageStories, + activeListType = activeListType + ) + } + + ActionMenuPopup( + visible = visible, + onDismiss = onDismiss, + modifier = Modifier + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = 56.dp, end = 12.dp) + ) { + MenuToggleRow( + icon = Icons.Rounded.Image, + title = stringResource(R.string.story_media_fill_title), + isChecked = isMediaScaledToFill, + onCheckedChange = onMediaScaleToggle + ) + + if (menuActions.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) + ) + } + + menuActions.forEachIndexed { index, item -> + if (index > 0 && menuActions[index - 1].group != item.group) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) + ) + } + MenuOptionRow( + icon = storyViewerMenuIcon(item.action), + title = storyViewerMenuTitle(item.action), + destructive = item.action == StoryViewerMenuAction.DELETE, + onClick = { + onDismiss() + when (item.action) { + StoryViewerMenuAction.DOWNLOAD -> onDownload() + StoryViewerMenuAction.LINKS -> onLinks() + StoryViewerMenuAction.EDIT -> onEdit() + StoryViewerMenuAction.ARCHIVE -> onArchive() + StoryViewerMenuAction.RESTORE -> onRestore() + StoryViewerMenuAction.STATISTICS -> onStatistics() + StoryViewerMenuAction.DELETE -> onDelete() + } + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun StoryLinksSheetComponent( + urls: List, + onDismiss: () -> Unit, + onOpenLink: (String) -> Unit, + onCopyLink: (String) -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + dragHandle = { BottomSheetDefaults.DragHandle() }, + containerColor = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.story_links_title), + style = MaterialTheme.typography.headlineSmall + ) + Spacer(modifier = Modifier.height(16.dp)) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp) + ) { + itemsIndexed(urls, key = { index, url -> "$index-$url" }) { index, url -> + ListItem( + modifier = Modifier.clickable { onOpenLink(url) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + leadingContent = { + Icon(Icons.Rounded.Link, contentDescription = null) + }, + headlineContent = { + Text( + text = url, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + }, + trailingContent = { + IconButton(onClick = { onCopyLink(url) }) { + Icon( + imageVector = Icons.Rounded.ContentCopy, + contentDescription = stringResource(R.string.action_copy_clipboard) + ) + } + } + ) + if (index < urls.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun StoryInteractionsSheetComponent( + page: StoryInteractionPageModel?, + isLoading: Boolean, + onDismiss: () -> Unit, + onLoadMore: () -> Unit +) { + val context = LocalContext.current + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + dragHandle = { BottomSheetDefaults.DragHandle() }, + containerColor = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.story_interactions_title), + style = MaterialTheme.typography.headlineSmall + ) + if (page != null) { + Spacer(modifier = Modifier.height(12.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item { + StoryMetadataChip( + icon = Icons.Rounded.PeopleAlt, + label = stringResource( + R.string.story_interactions_views_format, + page.totalCount + ) + ) + } + item { + StoryMetadataChip( + icon = Icons.Rounded.Share, + label = stringResource( + R.string.story_interactions_shares_format, + page.totalForwardCount + ) + ) + } + item { + StoryMetadataChip( + icon = Icons.Rounded.Favorite, + label = stringResource( + R.string.story_interactions_reactions_format, + page.totalReactionCount + ) + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + when { + isLoading && page == null -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(220.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + page == null || page.interactions.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(220.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.story_interactions_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + else -> { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 480.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + itemsIndexed( + page.interactions, + key = { index, interaction -> + "${interaction.actorType}:${interaction.actorId}:${interaction.interactionDate}:$index" + } + ) { index, interaction -> + ListItem( + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + leadingContent = { + StoryInteractionAvatar( + avatarPath = interaction.actorAvatarPath, + isChat = interaction.actorType == StoryInteractionActorType.CHAT + ) + }, + headlineContent = { + Text( + text = interaction.actorTitle + ?: interaction.actorId.toString(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Text( + text = buildString { + append( + storyInteractionTypeLabel( + interaction.type, + interaction.reaction + ) + ) + append(" • ") + append( + formatStoryPostedTime( + context, + interaction.interactionDate + ) + ) + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + ) + if (index < page.interactions.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + ) + } + } + if (page.canLoadMore) { + item { + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton( + onClick = onLoadMore, + modifier = Modifier.fillMaxWidth() + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.story_interactions_load_more)) + } + } + } + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt new file mode 100644 index 000000000..debaddbc6 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt @@ -0,0 +1,1132 @@ +package org.monogram.presentation.features.stories + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.net.Uri +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.Public +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.WindowCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import kotlinx.coroutines.delay +import org.monogram.domain.models.stories.StoryAreaTypeModel +import org.monogram.domain.models.stories.StoryMediaType +import org.monogram.domain.models.stories.StoryModel +import org.monogram.presentation.R +import org.monogram.presentation.features.profile.components.StatisticsViewer +import java.io.File + +@Composable +internal fun StoryViewerScaffoldComponent( + state: StoriesHostComponent.State, + component: StoriesHostComponent, + story: StoryModel? +) { + val context = LocalContext.current + val downloadUtils: org.monogram.presentation.core.util.IDownloadUtils = + org.koin.compose.koinInject() + var currentProgress by remember(story?.id) { mutableFloatStateOf(0f) } + var restartPlaybackToken by remember(story?.id) { mutableStateOf(0) } + var isVideoMuted by remember(story?.id) { mutableStateOf(false) } + var isVideoPaused by remember(story?.id) { mutableStateOf(false) } + var isVideoBuffering by remember(story?.id) { mutableStateOf(story?.media?.type == StoryMediaType.VIDEO) } + var isVideoPlaying by remember(story?.id) { mutableStateOf(false) } + var isLinksSheetVisible by remember(story?.id) { mutableStateOf(false) } + var selectedSuggestedReaction by remember(story?.id) { + mutableStateOf( + null + ) + } + val isMediaScaledToFill = state.isStoryMediaStretchEnabled + val advanceStory by rememberUpdatedState(newValue = { + if (state.canGoNext) { + component.nextStory() + } else { + component.dismiss() + } + }) + + LaunchedEffect(story?.id, restartPlaybackToken, state.isLoading, story?.media?.type) { + currentProgress = 0f + if (story == null || state.isLoading || story.media.type != StoryMediaType.PHOTO) return@LaunchedEffect + val totalDurationMs = resolveStoryAutoAdvanceDurationMs(story) + val startMs = System.currentTimeMillis() + while (currentProgress < 1f) { + val elapsedMs = (System.currentTimeMillis() - startMs).coerceAtLeast(0L) + currentProgress = (elapsedMs.toFloat() / totalDurationMs.toFloat()).coerceIn(0f, 1f) + if (currentProgress >= 1f) break + delay(16) + } + advanceStory() + } + + val pageState = remember(story, state.viewerIndex, state.isLoading, state.inlineError) { + StoryViewerPageState( + story = story, + viewerIndex = state.viewerIndex, + isLoading = state.isLoading, + inlineError = state.inlineError + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + StoryViewerSystemBars() + StoryViewerBackground() + StoryStatusBarScrim() + + AnimatedContent( + targetState = pageState, + transitionSpec = { + fadeIn() togetherWith fadeOut() + }, + contentKey = { current -> current.story?.id ?: "story-${current.viewerIndex}" }, + label = "story_viewer_page" + ) { currentPage -> + StoryMediaScene( + context = context, + state = state, + page = currentPage, + progress = currentProgress, + isMediaScaledToFill = isMediaScaledToFill, + isVideoMuted = isVideoMuted, + isVideoPaused = isVideoPaused, + restartPlaybackToken = restartPlaybackToken, + onVideoProgress = { progressValue -> + currentProgress = progressValue.coerceIn(0f, 1f) + }, + onVideoBufferingChange = { isVideoBuffering = it }, + onVideoPlayingChange = { isVideoPlaying = it }, + onVideoCompleted = advanceStory, + onStoryAreaClick = { areaType -> + when (areaType) { + is StoryAreaTypeModel.Link -> component.openStoryLink(areaType.url) + is StoryAreaTypeModel.SuggestedReaction -> { + selectedSuggestedReaction = areaType.reaction + component.setStoryReaction(areaType.reaction) + } + + else -> Unit + } + }, + selectedReaction = selectedSuggestedReaction + ) + } + + Row(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable( + enabled = story != null, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { + if (shouldRestartCurrentStoryFromPreviousTap(currentProgress) || !state.canGoPrevious) { + restartPlaybackToken += 1 + isVideoPaused = false + } else { + component.previousStory() + } + } + ) + ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable( + enabled = story != null, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { + if (state.canGoNext) component.nextStory() else component.dismiss() + } + ) + ) + } + + StoryViewerChrome( + state = state, + story = story, + progress = currentProgress, + isVideo = story?.media?.type == StoryMediaType.VIDEO, + isVideoPaused = isVideoPaused, + isVideoMuted = isVideoMuted, + isVideoBuffering = isVideoBuffering, + isVideoPlaying = isVideoPlaying, + isMediaScaledToFill = isMediaScaledToFill, + onBack = component::dismiss, + onPauseToggle = { isVideoPaused = !isVideoPaused }, + onMuteToggle = { isVideoMuted = !isVideoMuted }, + onReactionClick = { + if (story?.canGetInteractions == true) { + component.showStoryInteractions() + } else { + val suggestedReaction = story?.areas + ?.firstNotNullOfOrNull { area -> + area.type as? StoryAreaTypeModel.SuggestedReaction + } + suggestedReaction?.let { + selectedSuggestedReaction = it.reaction + component.setStoryReaction(it.reaction) + } + } + }, + onMediaScaleToggle = component::setStoryMediaStretchEnabled, + onLinks = { isLinksSheetVisible = true }, + onEdit = component::editCurrentStory, + onArchive = component::moveCurrentStoryToArchive, + onRestore = component::restoreCurrentStoryFromArchive, + onStatistics = component::showStoryStatistics, + onDelete = component::deleteCurrentStory, + onDownload = { + resolveStoryDownloadPath(story)?.let(downloadUtils::saveFileToDownloads) + } + ) + + if (isLinksSheetVisible && !story?.linkUrls.isNullOrEmpty()) { + StoryLinksSheet( + urls = story.linkUrls, + onDismiss = { isLinksSheetVisible = false }, + onOpenLink = component::openStoryLink, + onCopyLink = component::copyStoryLink + ) + } + + if (state.isStoryStatisticsVisible) { + StatisticsViewer( + title = stringResource(R.string.story_statistics_title), + data = if (state.isStoryStatisticsLoading) null else state.storyStatistics, + onDismiss = component::dismissStoryStatistics + ) + } + + if (state.isStoryInteractionsVisible) { + StoryInteractionsSheet( + page = state.storyInteractionsPage, + isLoading = state.isStoryInteractionsLoading, + onDismiss = component::dismissStoryInteractions, + onLoadMore = component::loadMoreStoryInteractions + ) + } + } +} + +@Composable +private fun StoryViewerBackground() { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color(0xFF090A0E), + Color(0xFF10131B), + Color(0xFF06070B) + ) + ) + ) + ) +} + +@Composable +private fun StoryMediaScene( + context: Context, + state: StoriesHostComponent.State, + page: StoryViewerPageState, + progress: Float, + isMediaScaledToFill: Boolean, + isVideoMuted: Boolean, + isVideoPaused: Boolean, + restartPlaybackToken: Int, + onVideoProgress: (Float) -> Unit, + onVideoBufferingChange: (Boolean) -> Unit, + onVideoPlayingChange: (Boolean) -> Unit, + onVideoCompleted: () -> Unit, + onStoryAreaClick: (StoryAreaTypeModel) -> Unit, + selectedReaction: org.monogram.domain.models.stories.StoryReactionModel? +) { + val story = page.story + val mediaContentScale = if (isMediaScaledToFill) ContentScale.Crop else ContentScale.Fit + val videoResizeMode = if (isMediaScaledToFill) { + AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } else { + AspectRatioFrameLayout.RESIZE_MODE_FIT + } + + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val containerAspectRatio = maxWidth / maxHeight + val viewportModifier = if (isMediaScaledToFill) { + Modifier.fillMaxSize() + } else { + val fitModifier = if (containerAspectRatio > STORY_MEDIA_ASPECT_RATIO) { + Modifier + .fillMaxHeight() + .aspectRatio(STORY_MEDIA_ASPECT_RATIO) + } else { + Modifier + .fillMaxWidth() + .aspectRatio(STORY_MEDIA_ASPECT_RATIO) + } + Modifier + .align(Alignment.Center) + .then(fitModifier) + } + + Box(modifier = viewportModifier) { + when { + story == null && page.isLoading -> { + StoryMediaWaitingPlaceholder( + showLoadingText = state.showStoryMediaLoadingMessage + ) + } + + story == null -> { + StoryUnavailablePlaceholder( + page.inlineError ?: stringResource(R.string.story_viewer_unavailable) + ) + } + + story.media.type == StoryMediaType.PHOTO -> { + val storyImageModel = rememberStoryImageModel( + context = context, + primaryPath = story.media.path, + fallbackPath = story.media.previewPath, + minithumbnail = story.media.minithumbnail + ) + if (storyImageModel != null) { + AsyncImage( + model = storyImageModel, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = mediaContentScale + ) + if (page.isLoading) { + StoryMediaLoadingOverlay( + showLoadingText = state.showStoryMediaLoadingMessage + ) + } + } else if (page.isLoading) { + StoryMediaWaitingPlaceholder( + showLoadingText = state.showStoryMediaLoadingMessage + ) + } else { + StoryUnavailablePlaceholder( + page.inlineError ?: stringResource(R.string.story_viewer_unavailable) + ) + } + } + + else -> { + val previewModel = rememberStoryImageModel( + context = context, + primaryPath = story.media.previewPath, + fallbackPath = story.media.path, + minithumbnail = story.media.minithumbnail + ) + if (!story.media.path.isNullOrBlank()) { + StoryInlineVideoPlayer( + path = story.media.path.orEmpty(), + previewModel = previewModel, + previewContentScale = mediaContentScale, + resizeMode = videoResizeMode, + isMuted = isVideoMuted, + isPlaying = !isVideoPaused, + restartPlaybackToken = restartPlaybackToken, + onProgress = onVideoProgress, + onBufferingChange = onVideoBufferingChange, + onPlayingChange = onVideoPlayingChange, + onCompleted = onVideoCompleted + ) + } else if (previewModel != null) { + AsyncImage( + model = previewModel, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = mediaContentScale + ) + StoryMediaLoadingOverlay( + showLoadingText = state.showStoryMediaLoadingMessage + ) + } else if (page.isLoading) { + StoryMediaWaitingPlaceholder( + showLoadingText = state.showStoryMediaLoadingMessage + ) + } else { + StoryUnavailablePlaceholder( + page.inlineError ?: stringResource(R.string.story_viewer_unavailable) + ) + } + } + } + + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.40f), + Color.Transparent, + Color.Black.copy(alpha = 0.58f) + ) + ) + ) + ) + + if (story != null && story.areas.isNotEmpty()) { + StoryAreaOverlays( + story = story, + onAreaClick = onStoryAreaClick, + selectedReaction = selectedReaction + ) + } + } +} + +@Composable +private fun StoryAreaOverlays( + story: StoryModel, + onAreaClick: (StoryAreaTypeModel) -> Unit, + selectedReaction: org.monogram.domain.models.stories.StoryReactionModel? +) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + story.areas.forEachIndexed { index, area -> + val baseWidth = + maxOf(maxWidth * (area.position.widthPercentage.toFloat() / 100f), 72.dp) + val baseHeight = + maxOf(maxHeight * (area.position.heightPercentage.toFloat() / 100f), 34.dp) + val width: androidx.compose.ui.unit.Dp + val height: androidx.compose.ui.unit.Dp + val cornerRadius: androidx.compose.ui.unit.Dp + + if (area.type is StoryAreaTypeModel.SuggestedReaction) { + val diameter = maxOf(baseHeight, 58.dp) + width = diameter + height = diameter + cornerRadius = diameter / 2 + } else { + width = baseWidth + height = baseHeight + cornerRadius = maxOf( + maxWidth * (area.position.cornerRadiusPercentage.toFloat() / 100f), + 12.dp + ) + } + + val offsetX = (maxWidth * (area.position.xPercentage.toFloat() / 100f)) - (width / 2) + val offsetY = + (maxHeight * (area.position.yPercentage.toFloat() / 100f)) - (height / 2) + + StoryAreaChip( + modifier = Modifier + .padding(0.dp) + .graphicsLayer { rotationZ = area.position.rotationAngle.toFloat() } + .offset(x = offsetX, y = offsetY) + .size(width = width, height = height), + areaType = area.type, + cornerRadius = cornerRadius, + onClick = { onAreaClick(area.type) }, + key = "${story.id}-$index", + isSelected = (area.type as? StoryAreaTypeModel.SuggestedReaction)?.reaction == selectedReaction + ) + } + } +} + +@Composable +private fun StoryAreaChip( + modifier: Modifier, + areaType: StoryAreaTypeModel, + cornerRadius: androidx.compose.ui.unit.Dp, + onClick: () -> Unit, + key: String, + isSelected: Boolean +) { + val isClickable = remember(key, areaType) { + areaType is StoryAreaTypeModel.Link || areaType is StoryAreaTypeModel.SuggestedReaction + } + val selectedScale by animateFloatAsState( + targetValue = if (isSelected) 1.08f else 1f, + label = "story_area_selected_scale" + ) + val backgroundColor = remember(key, areaType) { + when (areaType) { + is StoryAreaTypeModel.Link -> Color.White.copy(alpha = 0.18f) + is StoryAreaTypeModel.SuggestedReaction -> { + if (areaType.isDark) Color.Black.copy(alpha = 0.68f) else Color.White.copy(alpha = 0.24f) + } + + is StoryAreaTypeModel.Weather -> Color(areaType.backgroundColorArgb).copy(alpha = 0.88f) + is StoryAreaTypeModel.Location -> Color(0xCC0F7A5B) + is StoryAreaTypeModel.Venue -> Color(0xCC155EEF) + is StoryAreaTypeModel.Message -> Color(0xCC7A4B1A) + is StoryAreaTypeModel.UpgradedGift -> Color(0xCC6A34D7) + } + } + val contentColor = remember(key, areaType) { + when (areaType) { + is StoryAreaTypeModel.SuggestedReaction -> { + if (areaType.isDark) Color.White else Color.Black + } + + is StoryAreaTypeModel.Weather -> Color.White + else -> Color.White + } + } + + if (areaType is StoryAreaTypeModel.SuggestedReaction) { + Box( + modifier = modifier + .graphicsLayer { + scaleX = selectedScale + scaleY = selectedScale + } + .then(if (isClickable) Modifier.clickable(onClick = onClick) else Modifier), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .matchParentSize() + .blur(14.dp) + .background( + if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.34f) + } else { + backgroundColor.copy(alpha = 0.42f) + }, + CircleShape + ) + ) + Surface( + modifier = Modifier.fillMaxSize(), + shape = CircleShape, + color = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.42f) + } else { + backgroundColor + }, + border = BorderStroke( + if (isSelected) 2.dp else 1.dp, + if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.95f) + } else { + Color.White.copy(alpha = 0.14f) + } + ) + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (isSelected) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 6.dp, end = 6.dp) + .size(14.dp) + .background(Color.White.copy(alpha = 0.18f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = Color.White + ) + } + } + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 6.dp, vertical = 8.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = storyReactionLabel(areaType.reaction), + style = MaterialTheme.typography.titleMedium, + color = contentColor, + maxLines = 1 + ) + Text( + text = formatCompactStoryCount(areaType.totalCount), + style = MaterialTheme.typography.labelSmall, + color = contentColor.copy(alpha = 0.84f), + maxLines = 1 + ) + } + } + } + } + } else { + Surface( + modifier = modifier + .clip(RoundedCornerShape(cornerRadius)) + .then(if (isClickable) Modifier.clickable(onClick = onClick) else Modifier), + shape = RoundedCornerShape(cornerRadius), + color = backgroundColor, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + StoryAreaLeading(areaType = areaType, contentColor = contentColor) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center + ) { + Text( + text = storyAreaPrimaryLabel(areaType), + style = MaterialTheme.typography.labelLarge, + color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + storyAreaSecondaryLabel(areaType)?.let { subtitle -> + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall, + color = contentColor.copy(alpha = 0.82f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } +} + +@Composable +private fun StoryAreaLeading( + areaType: StoryAreaTypeModel, + contentColor: Color +) { + when (areaType) { + is StoryAreaTypeModel.SuggestedReaction -> { + Text( + text = storyReactionLabel(areaType.reaction), + style = MaterialTheme.typography.titleMedium, + color = contentColor, + maxLines = 1 + ) + } + + is StoryAreaTypeModel.Weather -> { + Text( + text = areaType.emoji, + style = MaterialTheme.typography.titleMedium, + color = contentColor, + maxLines = 1 + ) + } + + else -> { + Icon( + imageVector = when (areaType) { + is StoryAreaTypeModel.Link -> Icons.Rounded.Link + is StoryAreaTypeModel.Location -> Icons.Rounded.Public + is StoryAreaTypeModel.Venue -> Icons.Rounded.Public + is StoryAreaTypeModel.Message -> Icons.Rounded.Share + is StoryAreaTypeModel.UpgradedGift -> Icons.Rounded.Image + }, + contentDescription = null, + tint = contentColor + ) + } + } +} + +@Composable +internal fun StoryUnavailablePlaceholder(message: String) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Surface( + shape = RoundedCornerShape(30.dp), + color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.82f) + ) { + Text( + text = message, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +internal fun rememberStoryImageModel( + context: Context, + primaryPath: String?, + fallbackPath: String?, + minithumbnail: ByteArray? +): Any? { + val resolvedPath = remember(primaryPath, fallbackPath) { + listOfNotNull( + primaryPath?.takeIf { it.isNotBlank() }, + fallbackPath?.takeIf { it.isNotBlank() } + ).firstOrNull() + } + + if (resolvedPath == null && minithumbnail != null && minithumbnail.isNotEmpty()) { + return remember(minithumbnail) { + ImageRequest.Builder(context) + .data(minithumbnail) + .build() + } + } + + resolvedPath ?: return null + + return remember(resolvedPath) { + if ( + resolvedPath.startsWith("http://") || + resolvedPath.startsWith("https://") || + resolvedPath.startsWith("content:") || + resolvedPath.startsWith("file:") + ) { + resolvedPath + } else { + val file = File(resolvedPath) + if (file.exists()) { + ImageRequest.Builder(context) + .data(file) + .memoryCacheKey("${file.absolutePath}:${file.lastModified()}:${file.length()}") + .diskCacheKey("${file.absolutePath}:${file.lastModified()}:${file.length()}") + .build() + } else { + ImageRequest.Builder(context) + .data(resolvedPath) + .build() + } + } + } +} + +@Composable +private fun StoryMediaWaitingPlaceholder( + showLoadingText: Boolean +) { + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.20f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.onPrimary) + } + if (showLoadingText) { + StoryMediaLoadingBadge( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 92.dp), + showText = true + ) + } + } +} + +@Composable +private fun StoryMediaLoadingOverlay( + showLoadingText: Boolean +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.10f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + strokeWidth = 2.5.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + if (showLoadingText) { + StoryMediaLoadingBadge( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 92.dp), + showText = true + ) + } + } +} + +@Composable +private fun StoryMediaLoadingBadge( + modifier: Modifier = Modifier, + showText: Boolean +) { + val transition = rememberInfiniteTransition(label = "story_loading_badge") + val pulseAlpha by transition.animateFloat( + initialValue = 0.14f, + targetValue = 0.28f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "story_loading_badge_alpha" + ) + + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .matchParentSize() + .blur(18.dp) + .background( + Color.Black.copy(alpha = pulseAlpha), + RoundedCornerShape(22.dp) + ) + ) + Surface( + modifier = Modifier.animateContentSize(), + shape = RoundedCornerShape(if (showText) 22.dp else 18.dp), + color = Color.Black.copy(alpha = 0.30f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)) + ) { + Row( + modifier = Modifier.padding( + horizontal = if (showText) 14.dp else 10.dp, + vertical = 10.dp + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + if (showText) { + Text( + text = stringResource(R.string.story_media_loading), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.92f) + ) + } + } + } + } +} + +@Composable +private fun StoryStatusBarScrim() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.50f), + Color.Black.copy(alpha = 0.24f), + Color.Transparent + ) + ) + ) + ) +} + +@Composable +internal fun StoryViewerProgressRow( + total: Int, + currentIndex: Int, + currentProgress: Float +) { + if (total <= 0) return + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + repeat(total) { index -> + val progress = when { + index < currentIndex -> 1f + index == currentIndex -> currentProgress.coerceIn(0f, 1f) + else -> 0f + } + Box( + modifier = Modifier + .weight(1f) + .height(4.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.22f)) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(progress) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onPrimary) + ) + } + } + } +} + +@Composable +internal fun StoryInlineVideoPlayer( + path: String, + previewModel: Any?, + previewContentScale: ContentScale, + resizeMode: Int, + isMuted: Boolean, + isPlaying: Boolean, + restartPlaybackToken: Int, + onProgress: (Float) -> Unit, + onBufferingChange: (Boolean) -> Unit, + onPlayingChange: (Boolean) -> Unit, + onCompleted: () -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val currentOnCompleted by rememberUpdatedState(onCompleted) + val mediaUri = remember(path) { resolveVideoUri(path) } + var completed by remember(path) { mutableStateOf(false) } + + val exoPlayer = remember(path) { + ExoPlayer.Builder(context).build().apply { + repeatMode = Player.REPEAT_MODE_OFF + volume = if (isMuted) 0f else 1f + setMediaItem(MediaItem.fromUri(mediaUri)) + prepare() + } + } + + DisposableEffect(exoPlayer, lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { + exoPlayer.pause() + } + } + val listener = object : Player.Listener { + override fun onIsPlayingChanged(isPlayingState: Boolean) { + onPlayingChange(isPlayingState) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + onBufferingChange(playbackState == Player.STATE_BUFFERING) + if (playbackState == Player.STATE_ENDED && !completed) { + completed = true + onProgress(1f) + currentOnCompleted() + } + } + + override fun onPlayerError(error: PlaybackException) { + onBufferingChange(false) + onPlayingChange(false) + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + exoPlayer.addListener(listener) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + exoPlayer.removeListener(listener) + exoPlayer.release() + } + } + + LaunchedEffect(exoPlayer, isMuted) { + exoPlayer.volume = if (isMuted) 0f else 1f + } + + LaunchedEffect(exoPlayer, isPlaying) { + if (isPlaying) { + if (exoPlayer.playbackState == Player.STATE_ENDED) { + completed = false + exoPlayer.seekTo(0) + } + exoPlayer.play() + } else { + exoPlayer.pause() + } + } + + LaunchedEffect(exoPlayer, restartPlaybackToken) { + completed = false + exoPlayer.seekTo(0) + onProgress(0f) + if (isPlaying) { + exoPlayer.play() + } + } + + LaunchedEffect(exoPlayer) { + while (true) { + val duration = exoPlayer.duration + if (duration > 0L) { + onProgress( + (exoPlayer.currentPosition.toFloat() / duration.toFloat()).coerceIn(0f, 1f) + ) + } + delay(40) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + if (previewModel != null) { + AsyncImage( + model = previewModel, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = previewContentScale + ) + } + + AndroidView( + factory = { viewContext -> + PlayerView(viewContext).apply { + useController = false + this.resizeMode = resizeMode + player = exoPlayer + } + }, + update = { view -> + view.player = exoPlayer + view.resizeMode = resizeMode + }, + modifier = Modifier.fillMaxSize() + ) + } +} + +private fun resolveVideoUri(path: String): Uri { + return when { + path.startsWith("content:") || + path.startsWith("file:") || + path.startsWith("http://") || + path.startsWith("https://") -> Uri.parse(path) + + else -> Uri.fromFile(File(path)) + } +} + +@Composable +private fun StoryViewerSystemBars() { + val view = LocalView.current + DisposableEffect(view) { + val activity = view.context.findActivity() + val window = activity?.window + if (window == null) { + onDispose { } + } else { + val controller = WindowCompat.getInsetsController(window, view) + val previousLightStatusBars = controller.isAppearanceLightStatusBars + val previousStatusBarColor = window.statusBarColor + + controller.isAppearanceLightStatusBars = false + window.statusBarColor = Color.Black.copy(alpha = 0.40f).toArgb() + + onDispose { + controller.isAppearanceLightStatusBars = previousLightStatusBars + window.statusBarColor = previousStatusBarColor + } + } + } +} + +private tailrec fun Context.findActivity(): Activity? { + return when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null + } +} + +private data class StoryViewerPageState( + val story: StoryModel?, + val viewerIndex: Int, + val isLoading: Boolean, + val inlineError: String? +) diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 3364237eb..f2a25ee83 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -567,7 +567,8 @@ You can publish stories from your profile Your story New story - Pick a photo or video + Edit story + Pick Caption Privacy Everyone @@ -581,17 +582,48 @@ Keep on profile Protect content Publish story + Publish Publishing… + Save story + Saving… Previous Next Story unavailable Open stories Create story Archive + Mute story audio + Unmute story audio + Story reactions + Media is still loading + Stretch + Location + Venue + Message + Shared from chat + Gift + Tap to open + %1$s %2$d° %1$d active stories %1$d of %2$d Posting as %1$s + Editing as %1$s Change media + Preview + Story preview + Check the story before publishing + Back to editor + Photo editor + Video editor + Photo + Video + Photo editor + Video editor + Story details + Audience and timing + Keep the main controls compact and close to the preview + Play + Pause Choose the photo or video viewers will see first Optional text shown above your story Story settings @@ -600,6 +632,20 @@ Restrict forwarding and saving for viewers Attached widget This story includes a mini app link + Links + Story Statistics + Views and Shares + Reactions + Story Interactions + No interactions yet + Load more + %1$d views + %1$d shares + %1$d reactions + Viewed + Viewed with %1$s + Forwarded as message + Reposted as story %1$d story slots available Premium is required to publish stories from this account. This chat needs more boosts before it can publish stories. From 54be4fc11845c3695da7faa2fe6fb8dbe146da48 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:02:27 +0300 Subject: [PATCH 04/16] stories: stories features --- .../repository/AppPreferencesProvider.kt | 2 + .../presentation/core/util/AppPreferences.kt | 10 + .../features/chats/list/ChatListContent.kt | 232 +++++++++++++++--- .../stories/DefaultStoriesHostComponent.kt | 37 ++- .../features/stories/StoriesHostComponent.kt | 2 + .../features/stories/StoriesHostContent.kt | 104 ++++---- .../components/StoryViewerComponents.kt | 154 +++++++----- .../components/StoryViewerSceneComponents.kt | 64 ++++- .../presentation/root/DefaultRootComponent.kt | 8 +- .../chatSettings/ChatSettingsComponent.kt | 13 + .../chatSettings/ChatSettingsContent.kt | 9 + .../src/main/res/values-ru-rRU/string.xml | 87 +++++++ presentation/src/main/res/values/string.xml | 9 +- 13 files changed, 573 insertions(+), 158 deletions(-) diff --git a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt index 05d3e0516..25e25bf4e 100644 --- a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt +++ b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt @@ -71,6 +71,7 @@ interface AppPreferencesProvider { val isChatAnimationsEnabled: StateFlow val chatListMessageLines: StateFlow val showChatListPhotos: StateFlow + val showStoriesBlock: StateFlow val showReactions: StateFlow val showSponsoredMessagesForPremium: StateFlow val storyMediaStretchEnabled: StateFlow @@ -129,6 +130,7 @@ interface AppPreferencesProvider { fun setChatAnimationsEnabled(enabled: Boolean) fun setChatListMessageLines(lines: Int) fun setShowChatListPhotos(enabled: Boolean) + fun setShowStoriesBlock(enabled: Boolean) fun setShowReactions(enabled: Boolean) fun setShowSponsoredMessagesForPremium(enabled: Boolean) fun setStoryMediaStretchEnabled(enabled: Boolean) diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt index f47427d7c..dc6aa67e6 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt @@ -331,6 +331,9 @@ class AppPreferences( private val _showChatListPhotos = MutableStateFlow(prefs.getBoolean(KEY_SHOW_CHAT_LIST_PHOTOS, true)) override val showChatListPhotos: StateFlow = _showChatListPhotos + private val _showStoriesBlock = MutableStateFlow(prefs.getBoolean(KEY_SHOW_STORIES_BLOCK, true)) + override val showStoriesBlock: StateFlow = _showStoriesBlock + private val _showReactions = MutableStateFlow(prefs.getBoolean(KEY_SHOW_REACTIONS, true)) override val showReactions: StateFlow = _showReactions @@ -1002,6 +1005,11 @@ class AppPreferences( _showChatListPhotos.value = enabled } + override fun setShowStoriesBlock(enabled: Boolean) { + prefs.edit().putBoolean(KEY_SHOW_STORIES_BLOCK, enabled).apply() + _showStoriesBlock.value = enabled + } + override fun setShowReactions(enabled: Boolean) { prefs.edit().putBoolean(KEY_SHOW_REACTIONS, enabled).apply() _showReactions.value = enabled @@ -1222,6 +1230,7 @@ class AppPreferences( _isChatAnimationsEnabled.value = true _chatListMessageLines.value = 1 _showChatListPhotos.value = true + _showStoriesBlock.value = true _showSponsoredMessagesForPremium.value = false _storyMediaStretchEnabled.value = true _showAllChatsFolder.value = true @@ -1377,6 +1386,7 @@ class AppPreferences( private const val KEY_CHAT_ANIMATIONS_ENABLED = "chat_animations_enabled" private const val KEY_CHAT_LIST_MESSAGE_LINES = "chat_list_message_lines" private const val KEY_SHOW_CHAT_LIST_PHOTOS = "show_chat_list_photos" + private const val KEY_SHOW_STORIES_BLOCK = "show_stories_block" private const val KEY_SHOW_REACTIONS = "show_reactions" private const val KEY_SHOW_SPONSORED_MESSAGES_FOR_PREMIUM = "show_sponsored_messages_for_premium" diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt index e7a6b2616..12a1ebbba 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt @@ -7,9 +7,11 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animate import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn @@ -150,6 +152,7 @@ import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.ui.ScreenSwipeBackState +import org.monogram.presentation.core.ui.shimmerBackground import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.features.chats.common.ChatActionState import org.monogram.presentation.features.chats.conversation.ui.content.ReportChatDialog @@ -190,6 +193,7 @@ fun ChatListContent( val searchState by component.searchState.collectAsState() val storiesState by component.storiesState.collectAsState() val showAllChatsFolder by component.appPreferences.showAllChatsFolder.collectAsState() + val showStoriesBlock by component.appPreferences.showStoriesBlock.collectAsState() val isProjectChannelPromoDismissed by component.appPreferences.isProjectChannelPromoDismissed.collectAsState() val isPreview = previewMode != ChatListPreviewMode.Active @@ -391,7 +395,21 @@ fun ChatListContent( ) } } - val hasMainStoriesStrip = isMainView && mainStoryStripItems.isNotEmpty() + val hasMainStoryItems = isMainView && mainStoryStripItems.isNotEmpty() + val storiesHeaderMode = remember( + showStoriesBlock, + isMainView, + storiesState.isMainStoriesLoaded, + hasMainStoryItems + ) { + resolveStoriesHeaderMode( + showStoriesBlock = showStoriesBlock, + isStoriesContextVisible = isMainView, + isStoriesLoaded = storiesState.isMainStoriesLoaded, + hasStoryItems = hasMainStoryItems + ) + } + val hasMainStoriesStrip = storiesHeaderMode == StoriesHeaderMode.Stories val showCreateStoryStripButton = shouldShowCreateStoryStripButton( selectedFolderId = effectiveFoldersState.selectedFolderId, hasVisibleStories = hasMainStoriesStrip @@ -399,7 +417,7 @@ fun ChatListContent( val showCreateStoryFab = shouldShowCreateStoryFab( selectedFolderId = effectiveFoldersState.selectedFolderId, areMainStoriesLoaded = storiesState.isMainStoriesLoaded, - hasVisibleStories = hasMainStoriesStrip + hasVisibleStories = hasMainStoryItems ) val hasUnreadInCurrentFolder = remember(currentFolderChats) { currentFolderChats.any(::hasChatListUnreadState) @@ -445,7 +463,7 @@ fun ChatListContent( } val isArchiveRevealed = archiveRevealPx > 0f && !isArchivePersistent - val hasCollapsibleStories = hasMainStoriesStrip + val hasCollapsibleStories = storiesHeaderMode != StoriesHeaderMode.Hidden val hasCollapsibleFolderTabs = visibleFolders.size > 1 if (!isPreview) { BackHandler(enabled = isArchiveRevealed) { @@ -682,6 +700,21 @@ fun ChatListContent( tabsOffsetPx > -10f } } + val storiesVisibilityProgress by animateFloatAsState( + targetValue = if (storiesHeaderMode == StoriesHeaderMode.Hidden) 0f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow + ), + label = "StoriesHeaderVisibilityProgress" + ) + + LaunchedEffect(storiesHeaderMode, storiesVisibilityProgress) { + if (storiesHeaderMode == StoriesHeaderMode.Hidden && storiesVisibilityProgress <= 0.001f) { + storiesAnimationJob?.cancel() + storiesOffsetPx = 0f + } + } var cachedStatusEmojiPath by remember(uiState.currentUser?.id) { mutableStateOf(uiState.currentUser?.statusEmojiPath) @@ -876,7 +909,11 @@ fun ChatListContent( 0f } val visibleStoriesHeight = if (hasMainStoriesStrip) { - (storiesHeaderHeightPx + storiesOffsetPx).coerceAtLeast(0f) + (storiesHeaderHeightPx + storiesOffsetPx).coerceAtLeast(0f) * + storiesVisibilityProgress + } else if (storiesVisibilityProgress > 0f) { + (storiesHeaderHeightPx + storiesOffsetPx).coerceAtLeast(0f) * + storiesVisibilityProgress } else { 0f } @@ -938,33 +975,87 @@ fun ChatListContent( } } - if (hasMainStoriesStrip) { + if (storiesHeaderMode != StoriesHeaderMode.Hidden || storiesVisibilityProgress > 0f) { Box( modifier = Modifier .fillMaxWidth() .height(with(density) { - (storiesHeaderHeightPx + storiesOffsetPx).coerceAtLeast( - 0f - ) - .toDp() + val rawVisibleHeight = when (storiesHeaderMode) { + StoriesHeaderMode.Stories -> + (storiesHeaderHeightPx + storiesOffsetPx).coerceAtLeast( + 0f + ) + + StoriesHeaderMode.Skeleton -> + (storiesHeaderHeightPx + storiesOffsetPx).coerceAtLeast( + 0f + ) + + StoriesHeaderMode.Hidden -> storiesHeaderHeightPx + } + (rawVisibleHeight * storiesVisibilityProgress).toDp() }) .graphicsLayer { + val rawVisibleHeight = when (storiesHeaderMode) { + StoriesHeaderMode.Stories -> + (storiesHeaderHeightPx + storiesOffsetPx).coerceAtLeast( + 0f + ) + + StoriesHeaderMode.Skeleton -> + (storiesHeaderHeightPx + storiesOffsetPx).coerceAtLeast( + 0f + ) + + StoriesHeaderMode.Hidden -> storiesHeaderHeightPx + } alpha = - ((storiesHeaderHeightPx + storiesOffsetPx) / storiesHeaderHeightPx) + ((rawVisibleHeight / storiesHeaderHeightPx) * storiesVisibilityProgress) .coerceIn(0f, 1f) clip = true } ) { - StoriesStrip( - items = mainStoryStripItems, - onStoryClick = component::onStoryClicked, - showAddStoryButton = showCreateStoryStripButton && !isPreview, - onAddStoryClick = if (isPreview) null else component::onAddStoryClicked, - modifier = Modifier - .fillMaxWidth() - .height(storiesHeaderHeight) - .offset { IntOffset(0, storiesOffsetPx.roundToInt()) } - ) + AnimatedContent( + targetState = storiesHeaderMode, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "StoriesHeaderContent" + ) { mode -> + when (mode) { + StoriesHeaderMode.Stories -> { + StoriesStrip( + items = mainStoryStripItems, + onStoryClick = component::onStoryClicked, + showAddStoryButton = showCreateStoryStripButton && !isPreview, + onAddStoryClick = if (isPreview) null else component::onAddStoryClicked, + modifier = Modifier + .fillMaxWidth() + .height(storiesHeaderHeight) + .offset { + IntOffset( + 0, + storiesOffsetPx.roundToInt() + ) + } + ) + } + + StoriesHeaderMode.Skeleton -> { + StoriesStripSkeleton( + modifier = Modifier + .fillMaxWidth() + .height(storiesHeaderHeight) + .offset { + IntOffset( + 0, + storiesOffsetPx.roundToInt() + ) + } + ) + } + + StoriesHeaderMode.Hidden -> Unit + } + } } } } @@ -1094,6 +1185,8 @@ fun ChatListContent( onProjectChannelSubscribe = component::onProjectChannelSubscribe, onProjectChannelLater = component::onProjectChannelLater, archiveActiveStories = storiesState.archiveActiveStories, + areArchiveStoriesLoaded = storiesState.isArchiveStoriesLoaded, + showStoriesBlock = showStoriesBlock, onChatClicked = onChatClicked, onChatLongClicked = onChatLongClicked ) @@ -1554,6 +1647,8 @@ private fun ChatListBody( onProjectChannelSubscribe: () -> Unit, onProjectChannelLater: () -> Unit, archiveActiveStories: List, + areArchiveStoriesLoaded: Boolean, + showStoriesBlock: Boolean, onChatClicked: (Long) -> Unit, onChatLongClicked: (Long) -> Unit ) { @@ -1577,6 +1672,8 @@ private fun ChatListBody( showPhotos = showPhotos, interactionsEnabled = interactionsEnabled, archiveActiveStories = archiveActiveStories, + areArchiveStoriesLoaded = areArchiveStoriesLoaded, + showStoriesBlock = showStoriesBlock, onChatClicked = onChatClicked, onChatLongClicked = onChatLongClicked ) @@ -1629,6 +1726,8 @@ private fun SearchOrArchiveContent( showPhotos: Boolean, interactionsEnabled: Boolean, archiveActiveStories: List, + areArchiveStoriesLoaded: Boolean, + showStoriesBlock: Boolean, onChatClicked: (Long) -> Unit, onChatLongClicked: (Long) -> Unit ) { @@ -1666,9 +1765,21 @@ private fun SearchOrArchiveContent( ) } } - val hasArchiveStoriesStrip = isArchivedView && - shouldShowStoriesStrip(selectedFolderId = -2, isSearchActive = false) && - archiveStoryStripItems.isNotEmpty() + val archiveStoriesHeaderMode = remember( + showStoriesBlock, + isArchivedView, + areArchiveStoriesLoaded, + archiveStoryStripItems.isNotEmpty() + ) { + resolveStoriesHeaderMode( + showStoriesBlock = showStoriesBlock, + isStoriesContextVisible = isArchivedView && + shouldShowStoriesStrip(selectedFolderId = -2, isSearchActive = false), + isStoriesLoaded = areArchiveStoriesLoaded, + hasStoryItems = archiveStoryStripItems.isNotEmpty() + ) + } + val hasArchiveStoriesStrip = archiveStoriesHeaderMode == StoriesHeaderMode.Stories val isArchivedLoading = isArchivedView && chatsState.isLoading val hasArchivedLoadState = isArchivedView && ( foldersState.isLoadingByFolder.containsKey(-2) || chatsState.chats.isNotEmpty() @@ -1919,12 +2030,28 @@ private fun SearchOrArchiveContent( } } } else { - if (hasArchiveStoriesStrip) { + if (archiveStoriesHeaderMode != StoriesHeaderMode.Hidden) { item { - StoriesStrip( - items = archiveStoryStripItems, - onStoryClick = component::onStoryClicked - ) + AnimatedContent( + targetState = archiveStoriesHeaderMode, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "ArchiveStoriesHeaderContent" + ) { mode -> + when (mode) { + StoriesHeaderMode.Stories -> { + StoriesStrip( + items = archiveStoryStripItems, + onStoryClick = component::onStoryClicked + ) + } + + StoriesHeaderMode.Skeleton -> { + StoriesStripSkeleton(modifier = Modifier.fillMaxWidth()) + } + + StoriesHeaderMode.Hidden -> Unit + } + } } } @@ -3014,3 +3141,52 @@ internal fun shouldShowCreateStoryStripButton( ): Boolean { return selectedFolderId != -2 && hasVisibleStories } + +private enum class StoriesHeaderMode { + Hidden, + Skeleton, + Stories +} + +private fun resolveStoriesHeaderMode( + showStoriesBlock: Boolean, + isStoriesContextVisible: Boolean, + isStoriesLoaded: Boolean, + hasStoryItems: Boolean +): StoriesHeaderMode { + if (!showStoriesBlock || !isStoriesContextVisible) return StoriesHeaderMode.Hidden + if (hasStoryItems) return StoriesHeaderMode.Stories + if (!isStoriesLoaded) return StoriesHeaderMode.Skeleton + return StoriesHeaderMode.Hidden +} + +@Composable +private fun StoriesStripSkeleton(modifier: Modifier = Modifier) { + LazyRow( + modifier = modifier, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + itemsIndexed(List(6) { it }, key = { _, index -> "story_skeleton_$index" }) { index, _ -> + Column( + modifier = Modifier.width(72.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(68.dp) + .clip(CircleShape) + .shimmerBackground(CircleShape) + ) + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth(if (index % 2 == 0) 0.82f else 0.66f) + .height(10.dp) + .clip(RoundedCornerShape(999.dp)) + .shimmerBackground(RoundedCornerShape(999.dp)) + ) + } + } + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt index b0e52faad..a9fe24879 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt @@ -29,7 +29,8 @@ import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext class DefaultStoriesHostComponent( - context: AppComponentContext + context: AppComponentContext, + private val onProfileClicked: (Long) -> Unit = {} ) : StoriesHostComponent, AppComponentContext by context { private val authRepository: AuthRepository = container.repositories.authRepository private val storyRepository: StoryRepository = container.repositories.storyRepository @@ -655,6 +656,11 @@ class DefaultStoriesHostComponent( } } + override fun openProfile(chatId: Long) { + dismiss() + onProfileClicked(chatId) + } + override fun openStoryLink(url: String) { externalNavigator.openUrl(url) } @@ -664,6 +670,20 @@ class DefaultStoriesHostComponent( messageDisplayer.show(stringProvider.getString("link_copied")) } + override fun copyCurrentStoryLink() { + val story = _state.value.currentStory ?: return + scope.launch { + val username = resolvePublicStoryUsername(story.posterChatId) + if (username == null) { + messageDisplayer.show(stringProvider.getString("story_public_link_unavailable")) + return@launch + } + + clipManager.copyToClipboard("story_link", buildPublicStoryLink(username, story.id)) + messageDisplayer.show(stringProvider.getString("link_copied")) + } + } + override fun setStoryReaction(reaction: StoryReactionModel) { val story = _state.value.currentStory ?: return scope.launch { @@ -877,6 +897,17 @@ class DefaultStoriesHostComponent( return me?.id == chatId || chat?.isAdmin == true } + private suspend fun resolvePublicStoryUsername(chatId: Long): String? { + val chat = chatListRepository.getChatById(chatId) + val chatUsername = chat?.username?.takeIf { it.isNotBlank() } + ?: chat?.usernames?.activeUsernames?.firstOrNull { it.isNotBlank() } + if (chatUsername != null) return chatUsername + + val user = userRepository.getUser(chatId) + return user?.username?.takeIf { it.isNotBlank() } + ?: user?.usernames?.activeUsernames?.firstOrNull { it.isNotBlank() } + } + private suspend fun enrichStoryInteractions( page: StoryInteractionPageModel ): StoryInteractionPageModel { @@ -1181,3 +1212,7 @@ private data class ChatPresentation( val avatarPath: String?, val canManageStories: Boolean ) + +private fun buildPublicStoryLink(username: String, storyId: Int): String { + return "https://t.me/$username/s/$storyId" +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt index b225da997..afd932672 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt @@ -55,8 +55,10 @@ interface StoriesHostComponent { fun showStoryInteractions() fun dismissStoryInteractions() fun loadMoreStoryInteractions() + fun openProfile(chatId: Long) fun openStoryLink(url: String) fun copyStoryLink(url: String) + fun copyCurrentStoryLink() fun setStoryReaction(reaction: StoryReactionModel) fun setStoryMediaStretchEnabled(enabled: Boolean) fun dismissInlineVideo() diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt index f717a1850..f0b75151e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt @@ -6,22 +6,11 @@ import android.content.ContextWrapper import android.net.Uri import android.text.format.DateFormat import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -40,6 +29,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Archive import androidx.compose.material.icons.rounded.BarChart +import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Edit @@ -149,42 +139,15 @@ private fun StoriesOverlay( state: StoriesHostComponent.State, component: StoriesHostComponent ) { - AnimatedVisibility( - visible = state.isVisible, + Box( modifier = Modifier .fillMaxSize() - .zIndex(200f), - enter = fadeIn(tween(180)) + - scaleIn( - initialScale = 0.96f, - animationSpec = spring(dampingRatio = 0.92f, stiffness = 420f) - ) + - slideInVertically( - initialOffsetY = { it / 10 }, - animationSpec = tween(220, easing = FastOutSlowInEasing) - ), - exit = fadeOut(tween(160)) + - scaleOut( - targetScale = 0.98f, - animationSpec = tween(160, easing = FastOutSlowInEasing) - ) + - slideOutVertically( - targetOffsetY = { it / 14 }, - animationSpec = tween(180, easing = FastOutSlowInEasing) - ) + .zIndex(200f) ) { - AnimatedContent( - targetState = state.mode, - transitionSpec = { - fadeIn(tween(180)) togetherWith fadeOut(tween(120)) - }, - label = "stories_mode_switch" - ) { mode -> - when (mode) { - StoriesHostComponent.Mode.Viewer -> StoryViewerOverlay(state, component) - StoriesHostComponent.Mode.Composer -> StoryComposerOverlay(state, component) - StoriesHostComponent.Mode.Hidden -> Unit - } + when (state.mode) { + StoriesHostComponent.Mode.Viewer -> StoryViewerOverlay(state, component) + StoriesHostComponent.Mode.Composer -> StoryComposerOverlay(state, component) + StoriesHostComponent.Mode.Hidden -> Unit } } } @@ -249,13 +212,16 @@ internal fun StoryViewerChrome( onMuteToggle: () -> Unit, onReactionClick: () -> Unit, onMediaScaleToggle: (Boolean) -> Unit, + onProfileClick: (() -> Unit)?, onLinks: () -> Unit, onEdit: () -> Unit, onArchive: () -> Unit, onRestore: () -> Unit, onStatistics: () -> Unit, onDelete: () -> Unit, - onDownload: () -> Unit + onDownload: () -> Unit, + onCopyMedia: () -> Unit, + onCopyStoryLink: () -> Unit ) { StoryViewerChromeComponent( state = state, @@ -272,13 +238,16 @@ internal fun StoryViewerChrome( onMuteToggle = onMuteToggle, onReactionClick = onReactionClick, onMediaScaleToggle = onMediaScaleToggle, + onProfileClick = onProfileClick, onLinks = onLinks, onEdit = onEdit, onArchive = onArchive, onRestore = onRestore, onStatistics = onStatistics, onDelete = onDelete, - onDownload = onDownload + onDownload = onDownload, + onCopyMedia = onCopyMedia, + onCopyStoryLink = onCopyStoryLink ) } @@ -304,13 +273,15 @@ internal fun StoryInteractionsSheet( page: StoryInteractionPageModel?, isLoading: Boolean, onDismiss: () -> Unit, - onLoadMore: () -> Unit + onLoadMore: () -> Unit, + onInteractionClick: (Long) -> Unit ) { StoryInteractionsSheetComponent( page = page, isLoading = isLoading, onDismiss = onDismiss, - onLoadMore = onLoadMore + onLoadMore = onLoadMore, + onInteractionClick = onInteractionClick ) } @@ -503,6 +474,8 @@ internal data class StoryViewerMenuItem( internal enum class StoryViewerMenuAction { DOWNLOAD, + COPY_MEDIA, + COPY_STORY_LINK, LINKS, EDIT, ARCHIVE, @@ -731,6 +704,20 @@ internal fun buildStoryViewerMenuActions( ) ) } + if (resolveStoryDownloadPath(story) != null) { + add( + StoryViewerMenuItem( + action = StoryViewerMenuAction.COPY_MEDIA, + group = StoryViewerMenuGroup.CONTENT + ) + ) + } + add( + StoryViewerMenuItem( + action = StoryViewerMenuAction.COPY_STORY_LINK, + group = StoryViewerMenuGroup.CONTENT + ) + ) if (story.linkUrls.isNotEmpty()) { add( StoryViewerMenuItem( @@ -780,10 +767,20 @@ internal fun buildStoryViewerMenuActions( @Composable internal fun storyViewerMenuTitle( - action: StoryViewerMenuAction + action: StoryViewerMenuAction, + story: StoryModel? ): String { return when (action) { StoryViewerMenuAction.DOWNLOAD -> stringResource(R.string.action_download) + StoryViewerMenuAction.COPY_MEDIA -> stringResource( + if (story?.media?.type == StoryMediaType.VIDEO && !story?.media?.path.isNullOrBlank()) { + R.string.action_copy_frame + } else { + R.string.action_copy_image + } + ) + + StoryViewerMenuAction.COPY_STORY_LINK -> stringResource(R.string.action_copy_link) StoryViewerMenuAction.LINKS -> stringResource(R.string.story_links_title) StoryViewerMenuAction.EDIT -> stringResource(R.string.action_edit) StoryViewerMenuAction.ARCHIVE -> stringResource(R.string.story_archive) @@ -798,6 +795,8 @@ internal fun storyViewerMenuIcon( ): androidx.compose.ui.graphics.vector.ImageVector { return when (action) { StoryViewerMenuAction.DOWNLOAD -> Icons.Rounded.Download + StoryViewerMenuAction.COPY_MEDIA -> Icons.Rounded.ContentCopy + StoryViewerMenuAction.COPY_STORY_LINK -> Icons.Rounded.Link StoryViewerMenuAction.LINKS -> Icons.Rounded.Link StoryViewerMenuAction.EDIT -> Icons.Rounded.Edit StoryViewerMenuAction.ARCHIVE -> Icons.Rounded.Archive @@ -823,8 +822,9 @@ internal fun storyInteractionTypeLabel( } internal fun buildStoryPositionText(state: StoriesHostComponent.State): String { - val currentChatId = state.chatId - val totalInChat = state.viewerItems.count { it.chatId == currentChatId }.coerceAtLeast(1) + val currentChatId = state.chatId ?: return "" + val totalInChat = state.viewerItems.count { it.chatId == currentChatId } + if (totalInChat <= 1) return "" val currentIndexInChat = state.viewerItems .take(state.viewerIndex + 1) .count { it.chatId == currentChatId } @@ -899,4 +899,4 @@ internal fun StoryPostCapabilityModel?.toCapabilityPresentation(): StoryCapabili isBlocking = true ) } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt index 1432493cf..2bc0a4fc3 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt @@ -62,6 +62,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -95,13 +96,16 @@ internal fun StoryViewerChromeComponent( onMuteToggle: () -> Unit, onReactionClick: () -> Unit, onMediaScaleToggle: (Boolean) -> Unit, + onProfileClick: (() -> Unit)?, onLinks: () -> Unit, onEdit: () -> Unit, onArchive: () -> Unit, onRestore: () -> Unit, onStatistics: () -> Unit, onDelete: () -> Unit, - onDownload: () -> Unit + onDownload: () -> Unit, + onCopyMedia: () -> Unit, + onCopyStoryLink: () -> Unit ) { var showMenu by remember(story?.id, state.activeListType, state.canManageStories) { mutableStateOf(false) @@ -138,7 +142,8 @@ internal fun StoryViewerChromeComponent( state = state, onBack = onBack, showMoreButton = story != null, - onMore = { showMenu = true } + onMore = { showMenu = true }, + onProfileClick = onProfileClick ) Box(modifier = Modifier.fillMaxWidth()) { StoryViewerActionsPopup( @@ -150,6 +155,8 @@ internal fun StoryViewerChromeComponent( onDismiss = { showMenu = false }, onMediaScaleToggle = onMediaScaleToggle, onDownload = onDownload, + onCopyMedia = onCopyMedia, + onCopyStoryLink = onCopyStoryLink, onLinks = onLinks, onEdit = onEdit, onArchive = onArchive, @@ -264,10 +271,18 @@ private fun StoryViewerHeader( state: StoriesHostComponent.State, onBack: () -> Unit, showMoreButton: Boolean, - onMore: () -> Unit + onMore: () -> Unit, + onProfileClick: (() -> Unit)? ) { val context = LocalContext.current val showSkeleton = state.chatTitle.isBlank() + val profileClickModifier = remember(showSkeleton, onProfileClick) { + if (!showSkeleton && onProfileClick != null) { + Modifier.clickable(onClick = onProfileClick) + } else { + Modifier + } + } val selectedItem = state.viewerItems.getOrNull(state.viewerIndex) val headerInfo = remember( state.chatId, @@ -296,69 +311,76 @@ private fun StoryViewerHeader( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - Surface( - shape = CircleShape, - color = if (showSkeleton) Color.White.copy(alpha = 0.16f) else Color.White.copy( - alpha = 0.12f - ), - modifier = Modifier.size(38.dp) + Row( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(18.dp)) + .then(profileClickModifier) + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - if (showSkeleton) { - StorySkeletonPlaceholder() - } else if (state.chatAvatarPath != null) { - AsyncImage( - model = state.chatAvatarPath, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } else { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Rounded.Image, + Surface( + shape = CircleShape, + color = if (showSkeleton) Color.White.copy(alpha = 0.16f) else Color.White.copy( + alpha = 0.12f + ), + modifier = Modifier.size(38.dp) + ) { + if (showSkeleton) { + StorySkeletonPlaceholder() + } else if (state.chatAvatarPath != null) { + AsyncImage( + model = state.chatAvatarPath, contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.9f) + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Image, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.9f) + ) + } } } - } - Box(modifier = Modifier.weight(1f)) { - AnimatedContent( - targetState = headerInfo, - transitionSpec = { - (fadeIn() + slideInVertically { it / 4 }) togetherWith - (fadeOut() + slideOutVertically { -it / 4 }) - }, - label = "story_header_info" - ) { currentInfo -> - if (showSkeleton) { - StoryHeaderSkeleton() - } else { - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = currentInfo.title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = buildString { - append(currentInfo.positionText) - if (currentInfo.postedAt.isNotBlank()) { - append(" • ") - append(currentInfo.postedAt) - } - }, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.72f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Box(modifier = Modifier.weight(1f)) { + AnimatedContent( + targetState = headerInfo, + transitionSpec = { + (fadeIn() + slideInVertically { it / 4 }) togetherWith + (fadeOut() + slideOutVertically { -it / 4 }) + }, + label = "story_header_info" + ) { currentInfo -> + if (showSkeleton) { + StoryHeaderSkeleton() + } else { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = currentInfo.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = listOfNotNull( + currentInfo.positionText.takeIf { it.isNotBlank() }, + currentInfo.postedAt.takeIf { it.isNotBlank() } + ).joinToString(" • "), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.72f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } } } @@ -395,6 +417,8 @@ private fun StoryViewerActionsPopup( onDismiss: () -> Unit, onMediaScaleToggle: (Boolean) -> Unit, onDownload: () -> Unit, + onCopyMedia: () -> Unit, + onCopyStoryLink: () -> Unit, onLinks: () -> Unit, onEdit: () -> Unit, onArchive: () -> Unit, @@ -442,12 +466,14 @@ private fun StoryViewerActionsPopup( } MenuOptionRow( icon = storyViewerMenuIcon(item.action), - title = storyViewerMenuTitle(item.action), + title = storyViewerMenuTitle(item.action, story), destructive = item.action == StoryViewerMenuAction.DELETE, onClick = { onDismiss() when (item.action) { StoryViewerMenuAction.DOWNLOAD -> onDownload() + StoryViewerMenuAction.COPY_MEDIA -> onCopyMedia() + StoryViewerMenuAction.COPY_STORY_LINK -> onCopyStoryLink() StoryViewerMenuAction.LINKS -> onLinks() StoryViewerMenuAction.EDIT -> onEdit() StoryViewerMenuAction.ARCHIVE -> onArchive() @@ -532,7 +558,8 @@ internal fun StoryInteractionsSheetComponent( page: StoryInteractionPageModel?, isLoading: Boolean, onDismiss: () -> Unit, - onLoadMore: () -> Unit + onLoadMore: () -> Unit, + onInteractionClick: (Long) -> Unit ) { val context = LocalContext.current ModalBottomSheet( @@ -624,6 +651,9 @@ internal fun StoryInteractionsSheetComponent( } ) { index, interaction -> ListItem( + modifier = Modifier.clickable { + onInteractionClick(interaction.actorId) + }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), leadingContent = { StoryInteractionAvatar( diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt index debaddbc6..934948776 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt @@ -4,6 +4,11 @@ import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy +import android.view.SurfaceView import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.LinearEasing @@ -71,6 +76,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.graphics.createBitmap import androidx.core.view.WindowCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -107,6 +113,7 @@ internal fun StoryViewerScaffoldComponent( var isVideoBuffering by remember(story?.id) { mutableStateOf(story?.media?.type == StoryMediaType.VIDEO) } var isVideoPlaying by remember(story?.id) { mutableStateOf(false) } var isLinksSheetVisible by remember(story?.id) { mutableStateOf(false) } + var playerView by remember(story?.id) { mutableStateOf(null) } var selectedSuggestedReaction by remember(story?.id) { mutableStateOf( null @@ -183,7 +190,8 @@ internal fun StoryViewerScaffoldComponent( else -> Unit } }, - selectedReaction = selectedSuggestedReaction + selectedReaction = selectedSuggestedReaction, + onPlayerViewChanged = { playerView = it } ) } @@ -249,6 +257,7 @@ internal fun StoryViewerScaffoldComponent( } }, onMediaScaleToggle = component::setStoryMediaStretchEnabled, + onProfileClick = state.chatId?.let { chatId -> { component.openProfile(chatId) } }, onLinks = { isLinksSheetVisible = true }, onEdit = component::editCurrentStory, onArchive = component::moveCurrentStoryToArchive, @@ -257,7 +266,42 @@ internal fun StoryViewerScaffoldComponent( onDelete = component::deleteCurrentStory, onDownload = { resolveStoryDownloadPath(story)?.let(downloadUtils::saveFileToDownloads) - } + }, + onCopyMedia = { + when { + story == null -> Unit + story.media.type == StoryMediaType.PHOTO -> { + resolveStoryDownloadPath(story)?.let(downloadUtils::copyImageToClipboard) + } + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { + val surfaceView = playerView?.videoSurfaceView as? SurfaceView + if (surfaceView != null && surfaceView.width > 0 && surfaceView.height > 0) { + val bitmap = createBitmap(surfaceView.width, surfaceView.height) + PixelCopy.request( + surfaceView, + bitmap, + { result -> + if (result == PixelCopy.SUCCESS) { + downloadUtils.copyBitmapToClipboard(bitmap) + } else { + resolveStoryDownloadPath(story) + ?.let(downloadUtils::copyImageToClipboard) + } + }, + Handler(Looper.getMainLooper()) + ) + } else { + resolveStoryDownloadPath(story)?.let(downloadUtils::copyImageToClipboard) + } + } + + else -> { + resolveStoryDownloadPath(story)?.let(downloadUtils::copyImageToClipboard) + } + } + }, + onCopyStoryLink = component::copyCurrentStoryLink ) if (isLinksSheetVisible && !story?.linkUrls.isNullOrEmpty()) { @@ -282,7 +326,8 @@ internal fun StoryViewerScaffoldComponent( page = state.storyInteractionsPage, isLoading = state.isStoryInteractionsLoading, onDismiss = component::dismissStoryInteractions, - onLoadMore = component::loadMoreStoryInteractions + onLoadMore = component::loadMoreStoryInteractions, + onInteractionClick = component::openProfile ) } } @@ -320,7 +365,8 @@ private fun StoryMediaScene( onVideoPlayingChange: (Boolean) -> Unit, onVideoCompleted: () -> Unit, onStoryAreaClick: (StoryAreaTypeModel) -> Unit, - selectedReaction: org.monogram.domain.models.stories.StoryReactionModel? + selectedReaction: org.monogram.domain.models.stories.StoryReactionModel?, + onPlayerViewChanged: (PlayerView?) -> Unit ) { val story = page.story val mediaContentScale = if (isMediaScaledToFill) ContentScale.Crop else ContentScale.Fit @@ -412,7 +458,8 @@ private fun StoryMediaScene( onProgress = onVideoProgress, onBufferingChange = onVideoBufferingChange, onPlayingChange = onVideoPlayingChange, - onCompleted = onVideoCompleted + onCompleted = onVideoCompleted, + onPlayerViewChanged = onPlayerViewChanged ) } else if (previewModel != null) { AsyncImage( @@ -964,7 +1011,8 @@ internal fun StoryInlineVideoPlayer( onProgress: (Float) -> Unit, onBufferingChange: (Boolean) -> Unit, onPlayingChange: (Boolean) -> Unit, - onCompleted: () -> Unit + onCompleted: () -> Unit, + onPlayerViewChanged: (PlayerView?) -> Unit = {} ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current @@ -1011,6 +1059,7 @@ internal fun StoryInlineVideoPlayer( exoPlayer.addListener(listener) onDispose { + onPlayerViewChanged(null) lifecycleOwner.lifecycle.removeObserver(observer) exoPlayer.removeListener(listener) exoPlayer.release() @@ -1070,11 +1119,12 @@ internal fun StoryInlineVideoPlayer( useController = false this.resizeMode = resizeMode player = exoPlayer - } + }.also(onPlayerViewChanged) }, update = { view -> view.player = exoPlayer view.resizeMode = resizeMode + onPlayerViewChanged(view) }, modifier = Modifier.fillMaxSize() ) diff --git a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt index de123c195..742c87105 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt @@ -116,11 +116,13 @@ class DefaultRootComponent( private val userRepository: UserRepository = container.repositories.userRepository private val cacheProvider: CacheProvider = container.cacheProvider + private val navigation = StackNavigation() override val appPreferences: AppPreferences = container.preferences.appPreferences override val videoPlayerPool: VideoPlayerPool = container.utils.videoPlayerPool - override val storiesHost: StoriesHostComponent = DefaultStoriesHostComponent(this) - - private val navigation = StackNavigation() + override val storiesHost: StoriesHostComponent = DefaultStoriesHostComponent( + context = this, + onProfileClicked = { navigation.bringToFront(Config.Profile(it)) } + ) private val scope = componentScope private val proxyComponent by lazy(LazyThreadSafetyMode.NONE) { DefaultProxyComponent( diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt index 4a0f81d02..1d28eb3e2 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt @@ -95,6 +95,7 @@ interface ChatSettingsComponent { fun onCompressVideosChanged(enabled: Boolean) fun onChatListMessageLinesChanged(lines: Int) fun onShowChatListPhotosChanged(enabled: Boolean) + fun onShowStoriesBlockChanged(enabled: Boolean) fun onShowReactionsChanged(enabled: Boolean) data class State( @@ -165,6 +166,7 @@ interface ChatSettingsComponent { val compressVideos: Boolean = true, val chatListMessageLines: Int = 1, val showChatListPhotos: Boolean = true, + val showStoriesBlock: Boolean = true, val showReactions: Boolean = true, val isInstalledFromGooglePlay: Boolean = true ) @@ -244,6 +246,7 @@ class DefaultChatSettingsComponent( compressVideos = appPreferences.compressVideos.value, chatListMessageLines = appPreferences.chatListMessageLines.value, showChatListPhotos = appPreferences.showChatListPhotos.value, + showStoriesBlock = appPreferences.showStoriesBlock.value, showReactions = appPreferences.showReactions.value, isInstalledFromGooglePlay = distrManager.isInstalledFromGooglePlay() ) @@ -600,6 +603,12 @@ class DefaultChatSettingsComponent( } .launchIn(scope) + appPreferences.showStoriesBlock + .onEach { enabled -> + _state.update { it.copy(showStoriesBlock = enabled) } + } + .launchIn(scope) + loadWallpapers() checkEmojiFiles() } @@ -1222,6 +1231,10 @@ class DefaultChatSettingsComponent( appPreferences.setShowChatListPhotos(enabled) } + override fun onShowStoriesBlockChanged(enabled: Boolean) { + appPreferences.setShowStoriesBlock(enabled) + } + override fun onShowReactionsChanged(enabled: Boolean) { appPreferences.setShowReactions(enabled) } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt index f04d76481..19872b562 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt @@ -916,6 +916,15 @@ fun ChatSettingsContent(component: ChatSettingsComponent) { onCheckedChange = component::onArchiveAlwaysVisibleChanged ) } + SettingsSwitchTile( + icon = Icons.AutoMirrored.Rounded.StickyNote2, + title = stringResource(R.string.show_stories_block_title), + subtitle = stringResource(R.string.show_stories_block_subtitle), + checked = state.showStoriesBlock, + iconColor = pinkColor, + position = ItemPosition.MIDDLE, + onCheckedChange = component::onShowStoriesBlockChanged + ) SettingsSwitchTile( icon = Icons.Rounded.Link, title = stringResource(R.string.show_link_previews_title), diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index c3f58abfc..8f3d73cb5 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -702,6 +702,8 @@ Архив всегда в начале списка чатов Всегда показывать архив Не скрывать закреплённый архив при прокрутке + Показывать блок историй + Показывать истории над списком чатов и в архиве Предпросмотр ссылок Отображать превью для ссылок в сообщениях Использьзовать альтернативные превью для X, Twitter, и Bluesky ссылок @@ -2389,5 +2391,90 @@ Чек-лист Платный медиафайл Кость со ставкой + Новая история + Редактировать историю + Выбрать + Подпись + Конфиденциальность + Все + Контакты + Близкие друзья + Видна в течение + 6 ч + 12 ч + 24 ч + 48 ч + Оставить в профиле + Защитить содержимое + Опубликовать историю + Опубликовать + Публикуем… + Сохраняем… + История недоступна + Открыть истории + Создать историю + Архив + Выключить звук истории + Включить звук истории + Реакции к истории + Медиафайл всё ещё загружается + Растянуть + Местоположение + Место + Сообщение + Поделились из чата + Подарок + Нажмите, чтобы открыть + %1$s %2$d° + Активных историй: %1$d + %1$d из %2$d + Публикация от имени %1$s + Редактирование от имени %1$s + Изменить медиа + Предпросмотр + Предпросмотр истории + Проверьте историю перед публикацией + Вернуться в редактор + Фоторедактор + Видеоредактор + Фото + Видео + Фоторедактор + Видеоредактор + Подробности истории + Основные элементы управления должны быть компактными и располагаться рядом с предпросмотром + Воспроизвести + Пауза + Выберите фото или видео, которое пользователи увидят первым + Необязательный текст, отображаемый над вашей историей + Настройки истории + Выберите, как долго эта история будет видна + Показывать эту историю в вашем профиле после публикации + Запретить пользователям пересылать и сохранять историю + Прикреплённый виджет + Эта история содержит ссылку на мини-приложение + Ссылки + Статистика истории + Просмотры и пересылки + Реакции + Взаимодействия с историей + Пока нет взаимодействий + Загрузить ещё + Просмотров: %1$d + Пересылок: %1$d + Реакций: %1$d + Просмотрена + Просмотрена с %1$s + Переслана как сообщение + Опубликована как история + Доступно слотов для историй: %1$d + Для публикации историй от этого аккаунта требуется Premium. + Этому чату нужно больше бустов, чтобы публиковать истории. + Достигнут лимит активных историй. + Вы достигли недельного лимита историй. + Вы достигли месячного лимита историй. + Завершите активную live-историю, прежде чем публиковать новую. + Публикация историй сейчас недоступна. + Публичная ссылка для этой истории недоступна %1$d / %2$d diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index f2a25ee83..e00096bdd 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -565,7 +565,6 @@ Admin Profile stories You can publish stories from your profile - Your story New story Edit story Pick @@ -584,10 +583,7 @@ Publish story Publish Publishing… - Save story Saving… - Previous - Next Story unavailable Open stories Create story @@ -620,7 +616,6 @@ Photo editor Video editor Story details - Audience and timing Keep the main controls compact and close to the preview Play Pause @@ -1081,6 +1076,8 @@ Keep archived chats at the top of the list Always Show Pinned Archive Keep pinned archive visible even when scrolling + Show Stories Block + Display stories above the chat list and in archive Show Link Previews Display previews for links in messages Fix Link Previews @@ -2000,9 +1997,11 @@ Download Download Video Copy Image + Copy Frame Copy Text Copy Link Copy Link with Time + Public link unavailable for this story Forward Save to GIFs Save to Gallery From 95f4a5b3192373d1b9004a5c96a1536baedabd0a Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:12:26 +0300 Subject: [PATCH 05/16] translations: update translations --- .../src/main/res/values-es/string.xml | 112 ++++++++++++++++++ .../src/main/res/values-hy/string.xml | 112 ++++++++++++++++++ .../src/main/res/values-pt-rBR/string.xml | 112 ++++++++++++++++++ .../src/main/res/values-ru-rRU/string.xml | 2 + .../src/main/res/values-sk/string.xml | 112 ++++++++++++++++++ .../src/main/res/values-tr/string.xml | 112 ++++++++++++++++++ .../src/main/res/values-uk/string.xml | 112 ++++++++++++++++++ .../src/main/res/values-zh-rCN/string.xml | 112 ++++++++++++++++++ 8 files changed, 786 insertions(+) diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 39f797c11..06aed0e6f 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -2369,5 +2369,117 @@ Lista de tareas Contenido de pago Dado apostado + Nueva historia + Editar historia + Elegir + Pie de foto + Privacidad + Todos + Contactos + Mejores amigos + Visible durante + 6 h + 12 h + 24 h + 48 h + Mantener en el perfil + Proteger contenido + Publicar historia + Publicar + Publicando… + Guardando… + Historia no disponible + Abrir historias + Crear historia + Archivo + Silenciar audio de la historia + Activar audio de la historia + Reacciones de la historia + El contenido multimedia aún se está cargando + Estirar + Ubicación + Lugar + Mensaje + Compartido desde el chat + Regalo + Toca para abrir + %1$s %2$d° + %1$d historias activas + %1$d de %2$d + Publicando como %1$s + Editando como %1$s + Cambiar contenido + Vista previa + Vista previa de la historia + Revisa la historia antes de publicarla + Volver al editor + Editor de fotos + Editor de video + Foto + Video + Editor de fotos + Editor de video + Detalles de la historia + Mantén los controles principales compactos y cerca de la vista previa + Reproducir + Pausa + Elige la foto o el video que los usuarios verán primero + Texto opcional que se muestra encima de tu historia + Ajustes de la historia + Elige cuánto tiempo permanecerá visible esta historia + Mostrar esta historia en tu perfil después de publicarla + Restringir que los usuarios reenvíen y guarden la historia + Widget adjunto + Esta historia incluye un enlace a una mini app + Enlaces + Estadísticas de la historia + Vistas y compartidos + Reacciones + Interacciones de la historia + Aún no hay interacciones + Cargar más + %1$d vistas + %1$d compartidos + %1$d reacciones + Vista + Vista con %1$s + Reenviada como mensaje + Republicada como historia + %1$d espacios para historias disponibles + Se requiere Premium para publicar historias desde esta cuenta. + Este chat necesita más boosts para publicar historias. + Se alcanzó el límite de historias activas. + Has alcanzado el límite semanal de historias. + Has alcanzado el límite mensual de historias. + Termina la historia en vivo activa antes de publicar una nueva. + La publicación de historias no está disponible en este momento. + Firmar mensajes + Mostrar el nombre del remitente en las publicaciones del canal + Unirse antes de enviar + Exigir que los miembros se unan antes de publicar + Permitir que los miembros creen y gestionen temas + Mostrar bloque de historias + Mostrar historias encima de la lista de chats y en el archivo + Editar + El nombre es obligatorio + No se pudo guardar el contacto + No se pudo eliminar el contacto + Mostrar fijado + Copiar fotograma + Enlace público no disponible para esta historia + No se puede abrir el artículo + Compartir número de teléfono + Permitir que este contacto vea tu número. + Este contacto necesita que compartas tu número. + Mostrar anuncios + Volver a mostrar mensajes patrocinados en los feeds de canales + Patrocinado + Recomendado + Novedades de Monogram + Mantente al tanto con Monogram + Suscríbete a nuestro canal para recibir noticias sobre lanzamientos, correcciones y funciones útiles de la app. + Más tarde + Suscribirse + Suscribiéndose… %1$d / %2$d diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index bb6f28250..c5d9cfde9 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -2252,5 +2252,117 @@ Ստուգաթերթ Վճարովի մեդիա Խաղադրույքով զառ + Նոր պատմություն + Խմբագրել պատմությունը + Ընտրել + Նկարագիր + Գաղտնիություն + Բոլորը + Կոնտակտներ + Մտերիմ ընկերներ + Տեսանելի է + 6 ժ + 12 ժ + 24 ժ + 48 ժ + Պահել պրոֆիլում + Պաշտպանել բովանդակությունը + Հրապարակել պատմությունը + Հրապարակել + Հրապարակվում է… + Պահվում է… + Պատմությունը հասանելի չէ + Բացել պատմությունները + Ստեղծել պատմություն + Արխիվ + Անջատել պատմության ձայնը + Միացնել պատմության ձայնը + Պատմության արձագանքներ + Մեդիան դեռ բեռնվում է + Ձգել + Տեղադրություն + Վայր + Հաղորդագրություն + Կիսվել է չատից + Նվեր + Սեղմեք՝ բացելու համար + %1$s %2$d° + %1$d ակտիվ պատմություն + %1$d / %2$d + Հրապարակվում է որպես %1$s + Խմբագրվում է որպես %1$s + Փոխել մեդիան + Նախադիտում + Պատմության նախադիտում + Ստուգեք պատմությունը նախքան հրապարակումը + Վերադառնալ խմբագրիչ + Լուսանկարի խմբագրիչ + Տեսանյութի խմբագրիչ + Լուսանկար + Տեսանյութ + Լուսանկարի խմբագրիչ + Տեսանյութի խմբագրիչ + Պատմության մանրամասներ + Հիմնական կառավարիչները պահեք կոմպակտ և մոտ նախադիտմանը + Նվագարկել + Դադար + Ընտրեք լուսանկարը կամ տեսանյութը, որը օգտատերերը առաջինը կտեսնեն + Ընտրովի տեքստ, որը ցուցադրվում է ձեր պատմության վերևում + Պատմության կարգավորումներ + Ընտրեք, թե որքան ժամանակ պատմությունը տեսանելի կմնա + Ցույց տալ այս պատմությունը ձեր պրոֆիլում հրապարակումից հետո + Արգելել օգտատերերին վերահասցեավորել և պահպանել պատմությունը + Կցված վիջեթ + Այս պատմությունը ներառում է մինի հավելվածի հղում + Հղումներ + Պատմության վիճակագրություն + Դիտումներ և տարածումներ + Արձագանքներ + Պատմության փոխազդեցություններ + Դեռ փոխազդեցություններ չկան + Բեռնել ավելին + %1$d դիտում + %1$d տարածում + %1$d արձագանք + Դիտվել է + Դիտվել է %1$s-ով + Վերահասցեավորվել է որպես հաղորդագրություն + Վերահրապարակվել է որպես պատմություն + Հասանելի է %1$d պատմության տեղ + Premium-ը պարտադիր է այս հաշվից պատմություններ հրապարակելու համար։ + Այս չատին ավելի շատ boost-եր են պետք, որպեսզի կարողանա պատմություններ հրապարակել։ + Ակտիվ պատմությունների սահմանաչափը հասել է։ + Դուք հասել եք պատմությունների շաբաթական սահմանաչափին։ + Դուք հասել եք պատմությունների ամսական սահմանաչափին։ + Ավարտեք ակտիվ live-պատմությունը նախքան նորը հրապարակելը։ + Պատմությունների հրապարակումը հիմա հասանելի չէ։ + Ստորագրել հաղորդագրությունները + Ցուցադրել ուղարկողի անունը ալիքի գրառումներում + Միանալ նախքան ուղարկելը + Պահանջել, որ մասնակիցները միանան նախքան հրապարակելը + Թույլ տալ անդամներին ստեղծել և կառավարել թեմաները + Ցույց տալ պատմությունների բլոկը + Ցույց տալ պատմությունները չատերի ցանկի վերևում և արխիվում + Խմբագրել + Անունը պարտադիր է + Չհաջողվեց պահպանել կոնտակտը + Չհաջողվեց հեռացնել կոնտակտը + Ցույց տալ ամրացվածը + Պատճենել կադրը + Հանրային հղումը հասանելի չէ այս պատմության համար + Չհաջողվեց բացել հոդվածը + Կիսվել հեռախոսահամարով + Թույլ տալ այս կոնտակտին տեսնել ձեր համարը։ + Այս կոնտակտի համար պետք է կիսվել ձեր համարով։ + Ցույց տալ գովազդը + Կրկին ցուցադրել հովանավորվող հաղորդագրությունները ալիքների ժապավենում + Հովանավորվող + Առաջարկվող + Monogram-ի թարմացումներ + Եղեք տեղեկացված Monogram-ի հետ + Բաժանորդագրվեք մեր ալիքին՝ ստանալու նորություններ թողարկումների, շտկումների և հավելվածի օգտակար հնարավորությունների մասին։ + Ավելի ուշ + Բաժանորդագրվել + Բաժանորդագրվում է… %1$d / %2$d diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 00515c0c9..8413cafc0 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -2376,5 +2376,117 @@ Checklist Midia paga Dado apostado + Nova história + Editar história + Escolher + Legenda + Privacidade + Todos + Contatos + Amigos próximos + Visível por + 6 h + 12 h + 24 h + 48 h + Manter no perfil + Proteger conteúdo + Publicar história + Publicar + Publicando… + Salvando… + História indisponível + Abrir histórias + Criar história + Arquivo + Silenciar áudio da história + Ativar áudio da história + Reações da história + A mídia ainda está carregando + Esticar + Localização + Local + Mensagem + Compartilhado do chat + Presente + Toque para abrir + %1$s %2$d° + %1$d histórias ativas + %1$d de %2$d + Publicando como %1$s + Editando como %1$s + Alterar mídia + Prévia + Prévia da história + Verifique a história antes de publicar + Voltar ao editor + Editor de fotos + Editor de vídeos + Foto + Vídeo + Editor de fotos + Editor de vídeos + Detalhes da história + Mantenha os controles principais compactos e próximos da prévia + Reproduzir + Pausar + Escolha a foto ou o vídeo que os usuários verão primeiro + Texto opcional exibido acima da sua história + Configurações da história + Escolha por quanto tempo esta história ficará visível + Mostrar esta história no seu perfil após a publicação + Restringir encaminhamento e salvamento para os usuários + Widget anexado + Esta história inclui um link para mini app + Links + Estatísticas da história + Visualizações e compartilhamentos + Reações + Interações da história + Ainda não há interações + Carregar mais + %1$d visualizações + %1$d compartilhamentos + %1$d reações + Visualizada + Visualizada com %1$s + Encaminhada como mensagem + Republicada como história + %1$d vagas para histórias disponíveis + O Premium é necessário para publicar histórias desta conta. + Este chat precisa de mais boosts para publicar histórias. + O limite de histórias ativas foi atingido. + Você atingiu o limite semanal de histórias. + Você atingiu o limite mensal de histórias. + Finalize a live story ativa antes de publicar uma nova. + A publicação de histórias está indisponível no momento. + Assinar mensagens + Mostrar o nome do remetente nas postagens do canal + Entrar antes de enviar + Exigir que os membros entrem antes de publicar + Permitir que os membros criem e gerenciem tópicos + Mostrar bloco de histórias + Exibir histórias acima da lista de chats e no arquivo + Editar + O primeiro nome é obrigatório + Falha ao salvar contato + Falha ao remover contato + Mostrar fixado + Copiar quadro + Link público indisponível para esta história + Não foi possível abrir o artigo + Compartilhar número de telefone + Permitir que este contato veja seu número. + Este contato precisa que seu número seja compartilhado. + Mostrar anúncios + Mostrar novamente mensagens patrocinadas nos feeds de canais + Patrocinado + Recomendado + Atualizações do Monogram + Fique por dentro do Monogram + Inscreva-se em nosso canal para receber notícias sobre lançamentos, correções e recursos úteis do app. + Mais tarde + Inscrever-se + Inscrevendo-se… %1$d / %2$d diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 8f3d73cb5..1762fcd67 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -2476,5 +2476,7 @@ Завершите активную live-историю, прежде чем публиковать новую. Публикация историй сейчас недоступна. Публичная ссылка для этой истории недоступна + Копировать кадр + Не удалось открыть статью %1$d / %2$d diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 24e9b20ad..6ce369b3d 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -2498,5 +2498,117 @@ Kontrolny zoznam Platene media Stavkova kocka + Nový príbeh + Upraviť príbeh + Vybrať + Popis + Súkromie + Všetci + Kontakty + Blízki priatelia + Viditeľné počas + 6 h + 12 h + 24 h + 48 h + Ponechať v profile + Chrániť obsah + Publikovať príbeh + Publikovať + Publikovanie… + Ukladanie… + Príbeh nie je dostupný + Otvoriť príbehy + Vytvoriť príbeh + Archív + Stlmiť zvuk príbehu + Zapnúť zvuk príbehu + Reakcie na príbeh + Médiá sa stále načítavajú + Roztiahnuť + Poloha + Miesto + Správa + Zdieľané z chatu + Darček + Ťuknite pre otvorenie + %1$s %2$d° + %1$d aktívnych príbehov + %1$d z %2$d + Publikovanie ako %1$s + Upravovanie ako %1$s + Zmeniť médiá + Náhľad + Náhľad príbehu + Skontrolujte príbeh pred publikovaním + Späť do editora + Editor fotiek + Editor videa + Fotka + Video + Editor fotiek + Editor videa + Podrobnosti príbehu + Udržujte hlavné ovládacie prvky kompaktné a blízko náhľadu + Prehrať + Pozastaviť + Vyberte fotku alebo video, ktoré používatelia uvidia ako prvé + Voliteľný text zobrazený nad vaším príbehom + Nastavenia príbehu + Vyberte, ako dlho bude tento príbeh viditeľný + Zobraziť tento príbeh vo vašom profile po publikovaní + Obmedziť preposielanie a ukladanie príbehu pre používateľov + Pripojený widget + Tento príbeh obsahuje odkaz na miniaplikáciu + Odkazy + Štatistiky príbehu + Zobrazenia a zdieľania + Reakcie + Interakcie s príbehom + Zatiaľ žiadne interakcie + Načítať viac + %1$d zobrazení + %1$d zdieľaní + %1$d reakcií + Zobrazené + Zobrazené s %1$s + Preposlané ako správa + Znovu publikované ako príbeh + Dostupných slotov pre príbehy: %1$d + Na publikovanie príbehov z tohto účtu je potrebný Premium. + Tento chat potrebuje viac boostov, aby mohol publikovať príbehy. + Bol dosiahnutý limit aktívnych príbehov. + Dosiahli ste týždenný limit príbehov. + Dosiahli ste mesačný limit príbehov. + Dokončite aktívny live príbeh pred publikovaním nového. + Publikovanie príbehov momentálne nie je dostupné. + Podpisovať správy + Zobraziť meno odosielateľa v príspevkoch kanála + Pripojiť sa pred odoslaním + Vyžadovať, aby sa členovia pripojili pred uverejnením + Povoliť členom vytvárať a spravovať témy + Zobraziť blok príbehov + Zobraziť príbehy nad zoznamom chatov a v archíve + Upraviť + Meno je povinné + Nepodarilo sa uložiť kontakt + Nepodarilo sa odstrániť kontakt + Zobraziť pripnutie + Kopírovať snímku + Verejný odkaz pre tento príbeh nie je dostupný + Článok sa nepodarilo otvoriť + Zdieľať telefónne číslo + Povoliť tomuto kontaktu vidieť vaše číslo. + Tento kontakt potrebuje, aby ste zdieľali svoje číslo. + Zobrazovať reklamy + Znova zobrazovať sponzorované správy v kanálových feedoch + Sponzorované + Odporúčané + Novinky Monogram + Buďte v obraze s Monogramom + Prihláste sa na odber nášho kanála a získavajte novinky o vydaniach, opravách a užitočných funkciách aplikácie! + Neskôr + Prihlásiť sa na odber + Prihlasovanie na odber… %1$d / %2$d diff --git a/presentation/src/main/res/values-tr/string.xml b/presentation/src/main/res/values-tr/string.xml index 73de007f6..fc5973c1c 100644 --- a/presentation/src/main/res/values-tr/string.xml +++ b/presentation/src/main/res/values-tr/string.xml @@ -2346,5 +2346,117 @@ Kontrol listesi Ucretli medya Bahisli zar + Yeni hikaye + Hikayeyi düzenle + Seç + Açıklama + Gizlilik + Herkes + Kişiler + Yakın arkadaşlar + Şu süreyle görünür + 6 sa + 12 sa + 24 sa + 48 sa + Profilde tut + İçeriği koru + Hikaye yayınla + Yayınla + Yayınlanıyor… + Kaydediliyor… + Hikaye kullanılamıyor + Hikayeleri aç + Hikaye oluştur + Arşiv + Hikaye sesini kapat + Hikaye sesini aç + Hikaye tepkileri + Medya hâlâ yükleniyor + Uzat + Konum + Mekan + Mesaj + Sohbetten paylaşıldı + Hediye + Açmak için dokun + %1$s %2$d° + %1$d etkin hikaye + %1$d / %2$d + %1$s olarak yayınlanıyor + %1$s olarak düzenleniyor + Medyayı değiştir + Önizleme + Hikaye önizlemesi + Yayınlamadan önce hikayeyi kontrol edin + Düzenleyiciye dön + Fotoğraf düzenleyici + Video düzenleyici + Fotoğraf + Video + Fotoğraf düzenleyici + Video düzenleyici + Hikaye ayrıntıları + Ana denetimleri kompakt tutun ve önizlemeye yakın yerleştirin + Oynat + Duraklat + Kullanıcıların önce göreceği fotoğraf veya videoyu seçin + Hikayenizin üzerinde gösterilecek isteğe bağlı metin + Hikaye ayarları + Bu hikayenin ne kadar süre görünür kalacağını seçin + Bu hikayeyi yayınladıktan sonra profilinizde gösterin + Kullanıcıların hikayeyi iletmesini ve kaydetmesini kısıtlayın + Ekli araç + Bu hikaye bir mini uygulama bağlantısı içeriyor + Bağlantılar + Hikaye İstatistikleri + Görüntülemeler ve Paylaşımlar + Tepkiler + Hikaye Etkileşimleri + Henüz etkileşim yok + Daha fazla yükle + %1$d görüntüleme + %1$d paylaşım + %1$d tepki + Görüldü + %1$s ile görüntülendi + Mesaj olarak iletildi + Hikaye olarak yeniden paylaşıldı + %1$d hikaye slotu kullanılabilir + Bu hesaptan hikaye yayınlamak için Premium gerekir. + Bu sohbetin hikaye yayınlayabilmesi için daha fazla boost gerekir. + Etkin hikaye sınırına ulaşıldı. + Haftalık hikaye sınırına ulaştınız. + Aylık hikaye sınırına ulaştınız. + Yeni bir hikaye yayınlamadan önce etkin canlı hikayeyi bitirin. + Hikaye yayınlama şu anda kullanılamıyor. + Mesajları imzala + Kanal gönderilerinde gönderen adını göster + Göndermeden önce katıl + Üyelerin paylaşım yapmadan önce katılmasını zorunlu kıl + Üyelerin konu oluşturmasına ve yönetmesine izin ver + Hikayeler bloğunu göster + Hikayeleri sohbet listesinin üstünde ve arşivde göster + Düzenle + Ad gerekli + Kişi kaydedilemedi + Kişi kaldırılamadı + Sabitleneni göster + Kareyi kopyala + Bu hikaye için herkese açık bağlantı kullanılamıyor + Makale açılamıyor + Telefon numarasını paylaş + Bu kişinin numaranızı görmesine izin ver. + Bu kişiyle numaranızı paylaşmanız gerekiyor. + Reklamları göster + Kanal akışlarında sponsorlu mesajları yeniden göster + Sponsorlu + Önerilen + Monogram güncellemeleri + Monogram ile güncel kalın + Sürümler, düzeltmeler ve yararlı uygulama özellikleriyle ilgili haberleri almak için kanalımıza abone olun! + Daha sonra + Abone ol + Abone olunuyor… %1$d / %2$d diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index 23d974d4e..e719e7845 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -2365,5 +2365,117 @@ Чекліст Платний медіафайл Кістка зі ставкою + Нова історія + Редагувати історію + Обрати + Підпис + Конфіденційність + Усі + Контакти + Близькі друзі + Видима протягом + 6 год + 12 год + 24 год + 48 год + Залишити в профілі + Захистити вміст + Опублікувати історію + Опублікувати + Публікуємо… + Зберігаємо… + Історія недоступна + Відкрити історії + Створити історію + Архів + Вимкнути звук історії + Увімкнути звук історії + Реакції до історії + Медіафайл ще завантажується + Розтягнути + Місцезнаходження + Місце + Повідомлення + Поширено з чату + Подарунок + Натисніть, щоб відкрити + %1$s %2$d° + Активних історій: %1$d + %1$d із %2$d + Публікація від імені %1$s + Редагування від імені %1$s + Змінити медіа + Попередній перегляд + Попередній перегляд історії + Перевірте історію перед публікацією + Повернутися до редактора + Фоторедактор + Відеоредактор + Фото + Відео + Фоторедактор + Відеоредактор + Подробиці історії + Тримайте основні елементи керування компактними і близько до попереднього перегляду + Відтворити + Пауза + Виберіть фото або відео, яке користувачі побачать першим + Необов’язковий текст, що відображається над вашою історією + Налаштування історії + Виберіть, як довго ця історія буде видима + Показувати цю історію у вашому профілі після публікації + Заборонити користувачам пересилати та зберігати історію + Прикріплений віджет + Ця історія містить посилання на мінізастосунок + Посилання + Статистика історії + Перегляди та поширення + Реакції + Взаємодії з історією + Взаємодій поки немає + Завантажити ще + Переглядів: %1$d + Поширень: %1$d + Реакцій: %1$d + Переглянуто + Переглянуто з %1$s + Переслано як повідомлення + Опубліковано як історію + Доступно слотів для історій: %1$d + Для публікації історій від цього акаунта потрібен Premium. + Цьому чату потрібно більше бустів, щоб публікувати історії. + Досягнуто ліміт активних історій. + Ви досягли тижневого ліміту історій. + Ви досягли місячного ліміту історій. + Завершіть активну live-історію, перш ніж публікувати нову. + Публікація історій зараз недоступна. + Підписувати повідомлення + Показувати ім’я відправника в дописах каналу + Вступ перед надсиланням + Вимагати від учасників вступу перед публікацією + Дозволити учасникам створювати та керувати темами + Показувати блок історій + Показувати історії над списком чатів і в архіві + Редагувати + Ім’я обов’язкове + Не вдалося зберегти контакт + Не вдалося видалити контакт + Показати закріплене + Копіювати кадр + Публічне посилання для цієї історії недоступне + Не вдалося відкрити статтю + Поділитися номером телефону + Дозволити цьому контакту бачити ваш номер. + Цьому контакту потрібно, щоб ви поділилися своїм номером. + Показувати рекламу + Знову показувати спонсоровані повідомлення у стрічках каналів + Спонсороване + Рекомендоване + Оновлення Monogram + Будьте в курсі з Monogram + Підпишіться на наш канал, щоб отримувати новини про релізи, виправлення та корисні можливості застосунку! + Пізніше + Підписатися + Підписуємо… %1$d / %2$d diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 6ed9d8f1c..a89271ffd 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -2344,5 +2344,117 @@ 清单 付费媒体 押注骰子 + 新故事 + 编辑故事 + 选择 + 说明 + 隐私 + 所有人 + 联系人 + 密友 + 可见时长 + 6 小时 + 12 小时 + 24 小时 + 48 小时 + 保留在个人资料页 + 保护内容 + 发布故事 + 发布 + 发布中… + 保存中… + 故事不可用 + 打开故事 + 创建故事 + 归档 + 静音故事音频 + 取消静音故事音频 + 故事回应 + 媒体仍在加载 + 拉伸 + 位置 + 地点 + 消息 + 来自聊天的分享 + 礼物 + 点击打开 + %1$s %2$d° + %1$d 个活动故事 + 第 %1$d 个,共 %2$d 个 + 以 %1$s 身份发布 + 以 %1$s 身份编辑 + 更换媒体 + 预览 + 故事预览 + 发布前检查故事 + 返回编辑器 + 照片编辑器 + 视频编辑器 + 照片 + 视频 + 照片编辑器 + 视频编辑器 + 故事详情 + 让主要控件紧凑并靠近预览区域 + 播放 + 暂停 + 选择用户最先看到的照片或视频 + 显示在你的故事上方的可选文本 + 故事设置 + 选择此故事保持可见的时长 + 发布后在你的个人资料中显示此故事 + 限制用户转发和保存此故事 + 附加小组件 + 此故事包含一个迷你应用链接 + 链接 + 故事统计 + 浏览量和分享 + 回应 + 故事互动 + 还没有互动 + 加载更多 + %1$d 次浏览 + %1$d 次分享 + %1$d 次回应 + 已查看 + 已查看并作出 %1$s 反应 + 已转发为消息 + 已重新发布为故事 + %1$d 个故事发布名额可用 + 此账号需要 Premium 才能发布故事。 + 此聊天需要更多助力后才能发布故事。 + 已达到活动故事上限。 + 你已达到每周故事上限。 + 你已达到每月故事上限。 + 请先结束当前直播故事,再发布新故事。 + 当前无法发布故事。 + 签署消息 + 在频道帖子中显示发送者名称 + 发送前先加入 + 要求成员先加入后再发帖 + 允许成员创建和管理话题 + 显示故事区块 + 在聊天列表上方和归档中显示故事 + 编辑 + 名字为必填项 + 保存联系人失败 + 删除联系人失败 + 显示置顶 + 复制帧 + 此故事没有公开链接 + 无法打开文章 + 分享电话号码 + 允许此联系人查看你的号码。 + 此联系人需要共享你的号码。 + 显示广告 + 在频道信息流中再次显示赞助消息 + 赞助 + 推荐 + Monogram 更新 + 随时了解 Monogram 动态 + 订阅我们的频道,接收有关版本发布、修复和实用应用功能的消息! + 稍后 + 订阅 + 订阅中… %1$d / %2$d From 29e6ebe1cabaaea40d711e69b4cbed8348f2f327 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:27:42 +0300 Subject: [PATCH 06/16] stories: fix username text colors in chatlist --- .../features/stories/components/StoriesStripComponents.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoriesStripComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoriesStripComponents.kt index 8372f8ca2..38bef0c3c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoriesStripComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoriesStripComponents.kt @@ -91,6 +91,7 @@ internal fun StoryStripTileComponent( text = title, modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis @@ -131,6 +132,7 @@ internal fun AddStoryStripTileComponent( text = stringResource(R.string.story_create), modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis From 29effc539021801a43123cfc6b3966cdb9063c90 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:11:32 +0300 Subject: [PATCH 07/16] chats: fix issues with media send --- .../conversation/DefaultChatComponent.kt | 2 + .../logic/message-actions/MessageActions.kt | 545 +++++++++++------- .../ui/inputbar/InputPreviewSection.kt | 20 +- 3 files changed, 352 insertions(+), 215 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt index 9a669808f..224f19e1e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt @@ -65,6 +65,7 @@ import org.monogram.presentation.core.ui.ScreenSwipeBackState import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.componentScope +import org.monogram.presentation.features.chats.conversation.logic.PendingAttachmentSendRegistry import org.monogram.presentation.features.chats.conversation.logic.buildChatInitialLoadKey import org.monogram.presentation.features.chats.conversation.logic.effectiveThreadId import org.monogram.presentation.features.chats.conversation.logic.handleSendPendingAttachments @@ -330,6 +331,7 @@ class DefaultChatComponent( internal val mediaDownloadRetryCount = ConcurrentHashMap() internal val pendingSenderRefreshes = ConcurrentHashMap.newKeySet() internal val senderRefreshRequestedAtMs = ConcurrentHashMap() + internal val pendingAttachmentSendRegistry = PendingAttachmentSendRegistry() internal var chatInfoObserversStarted: Boolean = false internal var sponsoredMessageLoadingJob: Job? = null internal var unreadBackfillJob: Job? = null diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt index 409282401..4e393a3e3 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt @@ -32,7 +32,6 @@ import kotlin.math.max import kotlin.math.roundToInt private const val MaxCompressedPhotoLongSide = 3840 - internal data class PhotoCompressionProfile( val targetWidth: Int, val targetHeight: Int, @@ -45,10 +44,112 @@ internal sealed interface PendingAttachmentSendPlan { data class Individual(val attachments: List) : PendingAttachmentSendPlan } +internal class PendingAttachmentSendRegistry { + private val inFlightKeys = mutableSetOf() + + fun start(key: String): Boolean = synchronized(this) { + inFlightKeys.add(key) + } + + fun finish(key: String) = synchronized(this) { + inFlightKeys.remove(key) + } +} + +private data class RemovedStagedAttachments( + val attachments: List, + val insertAt: Int +) + private fun DefaultChatComponent.shouldAutoScrollAfterSend(isAtBottom: Boolean): Boolean { return _state.value.rootMessage == null && !isAtBottom } +private fun DefaultChatComponent.takeStagedAttachments(paths: Collection): RemovedStagedAttachments { + if (paths.isEmpty()) return RemovedStagedAttachments(emptyList(), 0) + val pathSet = paths.toSet() + val stagedAttachments = _state.value.stagedAttachments + val removed = stagedAttachments.filter { it.localPath in pathSet } + if (removed.isEmpty()) return RemovedStagedAttachments(emptyList(), stagedAttachments.size) + val insertAt = stagedAttachments.indexOfFirst { it.localPath in pathSet }.coerceAtLeast(0) + _state.update { state -> + state.copy(stagedAttachments = state.stagedAttachments.filterNot { it.localPath in pathSet }) + } + return RemovedStagedAttachments(attachments = removed, insertAt = insertAt) +} + +private fun DefaultChatComponent.restoreStagedAttachments(removed: RemovedStagedAttachments) { + if (removed.attachments.isEmpty()) return + _state.update { state -> + val merged = state.stagedAttachments.toMutableList() + val restored = removed.attachments.filter { attachment -> + merged.none { it.localPath == attachment.localPath } + } + if (restored.isEmpty()) return@update state + val insertAt = removed.insertAt.coerceIn(0, merged.size) + merged.addAll(insertAt, restored) + state.copy(stagedAttachments = merged) + } +} + +private fun buildPendingAttachmentSendToken( + operation: String, + targetChatId: Long, + threadId: Long?, + paths: List, + caption: String, + captionEntities: List, + sendOptions: MessageSendOptions +): String { + return buildString { + append(operation) + append('|') + append(targetChatId) + append('|') + append(threadId ?: 0L) + append('|') + append(paths.joinToString(separator = "\u001F")) + append('|') + append(caption) + append('|') + append(captionEntities.hashCode()) + append('|') + append(sendOptions.hashCode()) + } +} + +private inline fun DefaultChatComponent.launchPendingAttachmentSend( + operation: String, + paths: List, + caption: String, + captionEntities: List, + sendOptions: MessageSendOptions, + crossinline block: suspend () -> Unit +) { + val currentState = _state.value + val threadId = currentState.effectiveThreadId() + val targetChatId = currentState.effectiveThreadChatId(chatId) + val sendToken = buildPendingAttachmentSendToken( + operation = operation, + targetChatId = targetChatId, + threadId = threadId, + paths = paths, + caption = caption, + captionEntities = captionEntities, + sendOptions = sendOptions + ) + + if (!pendingAttachmentSendRegistry.start(sendToken)) return + + scope.launch { + try { + block() + } finally { + pendingAttachmentSendRegistry.finish(sendToken) + } + } +} + internal fun DefaultChatComponent.handleSendMessage( text: String, entities: List, @@ -98,47 +199,54 @@ internal fun DefaultChatComponent.handleSendPhoto( captionEntities: List = emptyList(), sendOptions: MessageSendOptions = MessageSendOptions() ) { - scope.launch { - val matchingAttachment = - _state.value.stagedAttachments.firstOrNull { it.localPath == photoPath } - val shouldCompress = appPreferences.compressPhotos.value + launchPendingAttachmentSend( + operation = "photo", + paths = listOf(photoPath), + caption = caption, + captionEntities = captionEntities, + sendOptions = sendOptions + ) { + val removedAttachments = takeStagedAttachments(listOf(photoPath)) + try { + val shouldCompress = appPreferences.compressPhotos.value - val finalPath = if (shouldCompress) { - withContext(Dispatchers.IO) { - compressPhotoForUpload(photoPath) ?: photoPath + val finalPath = if (shouldCompress) { + withContext(Dispatchers.IO) { + compressPhotoForUpload(photoPath) ?: photoPath + } + } else { + photoPath } - } else { - photoPath - } - val currentState = _state.value - val replyId = currentState.replyMessage?.id - val threadId = currentState.effectiveThreadId() - val targetChatId = currentState.effectiveThreadChatId(chatId) - repositoryMessage.sendPhoto( - chatId = targetChatId, - photoPath = finalPath, - caption = caption, - captionEntities = captionEntities, - replyToMsgId = replyId, - threadId = threadId, - sendOptions = sendOptions - ) - onCancelReply() - if (sendOptions.scheduleDate == null) { - clearDraftLinkPreviewAfterSend() - } - if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { - onScrollToBottom() - } - if (sendOptions.scheduleDate != null) { - loadScheduledMessages() - } - _state.update { state -> - state.copy(stagedAttachments = state.stagedAttachments.filterNot { it.localPath == photoPath }) - } - if (sendOptions.scheduleDate == null) { - cleanupTempAttachments(listOfNotNull(matchingAttachment)) + val currentState = _state.value + val replyId = currentState.replyMessage?.id + val threadId = currentState.effectiveThreadId() + val targetChatId = currentState.effectiveThreadChatId(chatId) + repositoryMessage.sendPhoto( + chatId = targetChatId, + photoPath = finalPath, + caption = caption, + captionEntities = captionEntities, + replyToMsgId = replyId, + threadId = threadId, + sendOptions = sendOptions + ) + onCancelReply() + if (sendOptions.scheduleDate == null) { + clearDraftLinkPreviewAfterSend() + } + if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { + onScrollToBottom() + } + if (sendOptions.scheduleDate != null) { + loadScheduledMessages() + } + if (sendOptions.scheduleDate == null) { + cleanupTempAttachments(removedAttachments.attachments) + } + } catch (e: Exception) { + restoreStagedAttachments(removedAttachments) + throw e } } } @@ -149,53 +257,60 @@ internal fun DefaultChatComponent.handleSendVideo( captionEntities: List = emptyList(), sendOptions: MessageSendOptions = MessageSendOptions() ) { - scope.launch { - val matchingAttachment = - _state.value.stagedAttachments.firstOrNull { it.localPath == videoPath } - val shouldCompress = appPreferences.compressVideos.value - - val finalPath = if (shouldCompress) { - processVideo( - inputPath = videoPath, - trimRange = VideoTrimRange(), - filter = null, - textElements = emptyList(), - quality = VideoQuality.P1080, - muteAudio = false, - context = this@handleSendVideo.cacheController.context - ) - } else { - videoPath - } + launchPendingAttachmentSend( + operation = "video", + paths = listOf(videoPath), + caption = caption, + captionEntities = captionEntities, + sendOptions = sendOptions + ) { + val removedAttachments = takeStagedAttachments(listOf(videoPath)) + try { + val shouldCompress = appPreferences.compressVideos.value + + val finalPath = if (shouldCompress) { + processVideo( + inputPath = videoPath, + trimRange = VideoTrimRange(), + filter = null, + textElements = emptyList(), + quality = VideoQuality.P1080, + muteAudio = false, + context = this@handleSendVideo.cacheController.context + ) + } else { + videoPath + } - val currentState = _state.value - val replyId = currentState.replyMessage?.id - val threadId = currentState.effectiveThreadId() - val targetChatId = currentState.effectiveThreadChatId(chatId) - repositoryMessage.sendVideo( - chatId = targetChatId, - videoPath = finalPath, - caption = caption, - captionEntities = captionEntities, - replyToMsgId = replyId, - threadId = threadId, - sendOptions = sendOptions - ) - onCancelReply() - if (sendOptions.scheduleDate == null) { - clearDraftLinkPreviewAfterSend() - } - if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { - onScrollToBottom() - } - if (sendOptions.scheduleDate != null) { - loadScheduledMessages() - } - _state.update { state -> - state.copy(stagedAttachments = state.stagedAttachments.filterNot { it.localPath == videoPath }) - } - if (sendOptions.scheduleDate == null) { - cleanupTempAttachments(listOfNotNull(matchingAttachment)) + val currentState = _state.value + val replyId = currentState.replyMessage?.id + val threadId = currentState.effectiveThreadId() + val targetChatId = currentState.effectiveThreadChatId(chatId) + repositoryMessage.sendVideo( + chatId = targetChatId, + videoPath = finalPath, + caption = caption, + captionEntities = captionEntities, + replyToMsgId = replyId, + threadId = threadId, + sendOptions = sendOptions + ) + onCancelReply() + if (sendOptions.scheduleDate == null) { + clearDraftLinkPreviewAfterSend() + } + if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { + onScrollToBottom() + } + if (sendOptions.scheduleDate != null) { + loadScheduledMessages() + } + if (sendOptions.scheduleDate == null) { + cleanupTempAttachments(removedAttachments.attachments) + } + } catch (e: Exception) { + restoreStagedAttachments(removedAttachments) + throw e } } } @@ -246,36 +361,44 @@ internal fun DefaultChatComponent.handleSendDocument( captionEntities: List = emptyList(), sendOptions: MessageSendOptions = MessageSendOptions() ) { - scope.launch { - val matchingAttachment = _state.value.stagedAttachments.firstOrNull { it.localPath == path } - val currentState = _state.value - val replyId = currentState.replyMessage?.id - val threadId = currentState.effectiveThreadId() - val targetChatId = currentState.effectiveThreadChatId(chatId) - repositoryMessage.sendDocument( - chatId = targetChatId, - documentPath = path, - caption = caption, - captionEntities = captionEntities, - replyToMsgId = replyId, - threadId = threadId, - sendOptions = sendOptions - ) - onCancelReply() - if (sendOptions.scheduleDate == null) { - clearDraftLinkPreviewAfterSend() - } - if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { - onScrollToBottom() - } - if (sendOptions.scheduleDate != null) { - loadScheduledMessages() - } - _state.update { state -> - state.copy(stagedAttachments = state.stagedAttachments.filterNot { it.localPath == path }) - } - if (sendOptions.scheduleDate == null) { - cleanupTempAttachments(listOfNotNull(matchingAttachment)) + launchPendingAttachmentSend( + operation = "document", + paths = listOf(path), + caption = caption, + captionEntities = captionEntities, + sendOptions = sendOptions + ) { + val removedAttachments = takeStagedAttachments(listOf(path)) + try { + val currentState = _state.value + val replyId = currentState.replyMessage?.id + val threadId = currentState.effectiveThreadId() + val targetChatId = currentState.effectiveThreadChatId(chatId) + repositoryMessage.sendDocument( + chatId = targetChatId, + documentPath = path, + caption = caption, + captionEntities = captionEntities, + replyToMsgId = replyId, + threadId = threadId, + sendOptions = sendOptions + ) + onCancelReply() + if (sendOptions.scheduleDate == null) { + clearDraftLinkPreviewAfterSend() + } + if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { + onScrollToBottom() + } + if (sendOptions.scheduleDate != null) { + loadScheduledMessages() + } + if (sendOptions.scheduleDate == null) { + cleanupTempAttachments(removedAttachments.attachments) + } + } catch (e: Exception) { + restoreStagedAttachments(removedAttachments) + throw e } } } @@ -315,36 +438,44 @@ internal fun DefaultChatComponent.handleSendGifFile( captionEntities: List = emptyList(), sendOptions: MessageSendOptions = MessageSendOptions() ) { - scope.launch { - val matchingAttachment = _state.value.stagedAttachments.firstOrNull { it.localPath == path } - val currentState = _state.value - val replyId = currentState.replyMessage?.id - val threadId = currentState.effectiveThreadId() - val targetChatId = currentState.effectiveThreadChatId(chatId) - repositoryMessage.sendGifFile( - chatId = targetChatId, - gifPath = path, - caption = caption, - captionEntities = captionEntities, - replyToMsgId = replyId, - threadId = threadId, - sendOptions = sendOptions - ) - onCancelReply() - if (sendOptions.scheduleDate == null) { - clearDraftLinkPreviewAfterSend() - } - if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { - onScrollToBottom() - } - if (sendOptions.scheduleDate != null) { - loadScheduledMessages() - } - _state.update { state -> - state.copy(stagedAttachments = state.stagedAttachments.filterNot { it.localPath == path }) - } - if (sendOptions.scheduleDate == null) { - cleanupTempAttachments(listOfNotNull(matchingAttachment)) + launchPendingAttachmentSend( + operation = "gif_file", + paths = listOf(path), + caption = caption, + captionEntities = captionEntities, + sendOptions = sendOptions + ) { + val removedAttachments = takeStagedAttachments(listOf(path)) + try { + val currentState = _state.value + val replyId = currentState.replyMessage?.id + val threadId = currentState.effectiveThreadId() + val targetChatId = currentState.effectiveThreadChatId(chatId) + repositoryMessage.sendGifFile( + chatId = targetChatId, + gifPath = path, + caption = caption, + captionEntities = captionEntities, + replyToMsgId = replyId, + threadId = threadId, + sendOptions = sendOptions + ) + onCancelReply() + if (sendOptions.scheduleDate == null) { + clearDraftLinkPreviewAfterSend() + } + if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { + onScrollToBottom() + } + if (sendOptions.scheduleDate != null) { + loadScheduledMessages() + } + if (sendOptions.scheduleDate == null) { + cleanupTempAttachments(removedAttachments.attachments) + } + } catch (e: Exception) { + restoreStagedAttachments(removedAttachments) + throw e } } } @@ -355,67 +486,75 @@ internal fun DefaultChatComponent.handleSendAlbum( captionEntities: List = emptyList(), sendOptions: MessageSendOptions = MessageSendOptions() ) { - scope.launch { - val matchingAttachments = _state.value.stagedAttachments.filter { it.localPath in paths } - val compressPhotos = appPreferences.compressPhotos.value - val compressVideos = appPreferences.compressVideos.value - - val processedPaths = paths.map { path -> - when { - path.endsWith(".mp4") -> { - if (compressVideos) { - processVideo( - inputPath = path, - trimRange = VideoTrimRange(), - filter = null, - textElements = emptyList(), - quality = VideoQuality.P1080, - muteAudio = false, - context = this@handleSendAlbum.cacheController.context - ) - } else path - } - - path.endsWith(".jpg") || path.endsWith(".jpeg") || path.endsWith(".png") -> { - if (compressPhotos) { - withContext(Dispatchers.IO) { - compressPhotoForUpload(path) ?: path - } - } else path + launchPendingAttachmentSend( + operation = "album", + paths = paths, + caption = caption, + captionEntities = captionEntities, + sendOptions = sendOptions + ) { + val removedAttachments = takeStagedAttachments(paths) + try { + val compressPhotos = appPreferences.compressPhotos.value + val compressVideos = appPreferences.compressVideos.value + + val processedPaths = paths.map { path -> + when { + path.endsWith(".mp4") -> { + if (compressVideos) { + processVideo( + inputPath = path, + trimRange = VideoTrimRange(), + filter = null, + textElements = emptyList(), + quality = VideoQuality.P1080, + muteAudio = false, + context = this@handleSendAlbum.cacheController.context + ) + } else path + } + + path.endsWith(".jpg") || path.endsWith(".jpeg") || path.endsWith(".png") -> { + if (compressPhotos) { + withContext(Dispatchers.IO) { + compressPhotoForUpload(path) ?: path + } + } else path + } + + else -> path } - - else -> path } - } - val currentState = _state.value - val replyId = currentState.replyMessage?.id - val threadId = currentState.effectiveThreadId() - val targetChatId = currentState.effectiveThreadChatId(chatId) - repositoryMessage.sendAlbum( - targetChatId, - processedPaths, - caption = caption, - captionEntities = captionEntities, - replyToMsgId = replyId, - threadId = threadId, - sendOptions = sendOptions - ) - onCancelReply() - if (sendOptions.scheduleDate == null) { - clearDraftLinkPreviewAfterSend() - } - if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { - onScrollToBottom() - } - if (sendOptions.scheduleDate != null) { - loadScheduledMessages() - } - _state.update { state -> - state.copy(stagedAttachments = state.stagedAttachments.filterNot { it.localPath in paths }) - } - if (sendOptions.scheduleDate == null) { - cleanupTempAttachments(matchingAttachments) + val currentState = _state.value + val replyId = currentState.replyMessage?.id + val threadId = currentState.effectiveThreadId() + val targetChatId = currentState.effectiveThreadChatId(chatId) + repositoryMessage.sendAlbum( + targetChatId, + processedPaths, + caption = caption, + captionEntities = captionEntities, + replyToMsgId = replyId, + threadId = threadId, + sendOptions = sendOptions + ) + onCancelReply() + if (sendOptions.scheduleDate == null) { + clearDraftLinkPreviewAfterSend() + } + if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { + onScrollToBottom() + } + if (sendOptions.scheduleDate != null) { + loadScheduledMessages() + } + if (sendOptions.scheduleDate == null) { + cleanupTempAttachments(removedAttachments.attachments) + } + } catch (e: Exception) { + restoreStagedAttachments(removedAttachments) + throw e } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputPreviewSection.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputPreviewSection.kt index 969f8c897..e4a2d46e2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputPreviewSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputPreviewSection.kt @@ -1010,6 +1010,13 @@ private fun AttachmentPreview( remember(attachments) { attachments.any { it.kind == PendingAttachmentKind.DOCUMENT } } val hasVisualMedia = remember(attachments) { attachments.any { it.kind != PendingAttachmentKind.DOCUMENT } } + val onAddClick = remember(hasVisualMedia, hasDocuments, onAdd, onAddDocuments) { + when { + hasVisualMedia -> onAdd + hasDocuments -> onAddDocuments + else -> onAdd + } + } Column( modifier = Modifier @@ -1034,7 +1041,7 @@ private fun AttachmentPreview( modifier = Modifier.padding(start = 4.dp) ) Row(verticalAlignment = Alignment.CenterVertically) { - TextButton(onClick = onAdd) { + TextButton(onClick = onAddClick) { Icon( imageVector = Icons.Outlined.Add, contentDescription = null, @@ -1043,17 +1050,6 @@ private fun AttachmentPreview( Spacer(modifier = Modifier.width(4.dp)) Text(text = stringResource(R.string.action_add)) } - if (hasVisualMedia && !hasDocuments) { - TextButton(onClick = onAddDocuments) { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(text = stringResource(R.string.action_add)) - } - } IconButton(onClick = onCancel, modifier = Modifier.size(24.dp)) { Icon( imageVector = Icons.Default.Close, From 0969989ea3c914535d29ae15b57c88ca64759d2d Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:20:46 +0300 Subject: [PATCH 08/16] tests: fix stories tests --- .../monogram/app/components/SwipeBackResolverTest.kt | 7 +++++++ .../org/monogram/data/infra/ConnectionManagerTest.kt | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/app/src/test/java/org/monogram/app/components/SwipeBackResolverTest.kt b/app/src/test/java/org/monogram/app/components/SwipeBackResolverTest.kt index a94555fe7..35f409462 100644 --- a/app/src/test/java/org/monogram/app/components/SwipeBackResolverTest.kt +++ b/app/src/test/java/org/monogram/app/components/SwipeBackResolverTest.kt @@ -165,6 +165,8 @@ class SwipeBackResolverTest { MutableStateFlow(ChatListComponent.SelectionState()) override val searchState: StateFlow = MutableStateFlow(ChatListComponent.SearchState()) + override val storiesState: StateFlow = + MutableStateFlow(ChatListComponent.StoriesState()) override val appPreferences: AppPreferences get() = throw UnsupportedOperationException("Not used by resolver tests") @@ -214,6 +216,11 @@ class SwipeBackResolverTest { override fun onDismissInstantView() = unsupported() override fun onOpenWebApp(url: String, botUserId: Long, botName: String) = unsupported() override fun onDismissWebApp() = unsupported() + override fun onShareToStory(mediaUrl: String, text: String?, widgetLink: String?) = + unsupported() + + override fun onStoryClicked(chatId: Long, storyId: Int?) = unsupported() + override fun onAddStoryClicked() = unsupported() override fun onOpenWebView(url: String) = unsupported() override fun onDismissWebView() = unsupported() override fun onUpdateClicked() = unsupported() diff --git a/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt b/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt index b9210c65a..38a0fc277 100644 --- a/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt +++ b/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt @@ -555,8 +555,10 @@ class ConnectionManagerTest { override val isChatAnimationsEnabled = MutableStateFlow(false) override val chatListMessageLines = MutableStateFlow(2) override val showChatListPhotos = MutableStateFlow(true) + override val showStoriesBlock = MutableStateFlow(true) override val showReactions = MutableStateFlow(true) override val showSponsoredMessagesForPremium = MutableStateFlow(false) + override val storyMediaStretchEnabled = MutableStateFlow(false) override val privateChatsNotifications = MutableStateFlow(true) override val groupsNotifications = MutableStateFlow(true) override val channelsNotifications = MutableStateFlow(true) @@ -609,6 +611,10 @@ class ConnectionManagerTest { showSponsoredMessagesForPremium.value = enabled } + override fun setStoryMediaStretchEnabled(enabled: Boolean) { + storyMediaStretchEnabled.value = enabled + } + override fun setProxyNetworkMode(networkType: ProxyNetworkType, mode: ProxyNetworkMode) { proxyNetworkRules.value = proxyNetworkRules.value.toMutableMap().apply { this[networkType] = (this[networkType] ?: ProxyNetworkRule(mode)).copy(mode = mode) @@ -719,6 +725,10 @@ class ConnectionManagerTest { showChatListPhotos.value = enabled } + override fun setShowStoriesBlock(enabled: Boolean) { + showStoriesBlock.value = enabled + } + override fun setShowReactions(enabled: Boolean) { showReactions.value = enabled } From 55e7ceba9ffbd221d5e9189787ddb9b9e6ab5ba0 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:20:59 +0300 Subject: [PATCH 09/16] scripts: make tests every build --- .github/workflows/android.yml | 12 +++++----- build.bat | 27 +++++++++++---------- build.gradle.kts | 45 +++++++++++++++++++++++++++++++++++ build.sh | 34 ++++++++++++++++---------- 4 files changed, 88 insertions(+), 30 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 32fab2182..4594fc42b 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -32,14 +32,14 @@ jobs: - name: Build libvpx run: cd presentation/src/main/cpp && ./build.sh - - name: Build Official Libre Debug APK - run: ./gradlew assembleOfficialLibreDebug - - name: Run Official Libre Debug Unit Tests run: ./gradlew testOfficialLibreDebugUnitTest -# - name: Build Telemt Libre Debug APK -# run: ./gradlew assembleTelemtLibreDebug -# + - name: Build Official Libre Debug APK + run: ./gradlew assembleOfficialLibreDebug + # - name: Run Telemt Libre Debug Unit Tests # run: ./gradlew testTelemtLibreDebugUnitTest +# +# - name: Build Telemt Libre Debug APK +# run: ./gradlew assembleTelemtLibreDebug diff --git a/build.bat b/build.bat index a22ada2a2..3e25e746f 100644 --- a/build.bat +++ b/build.bat @@ -90,9 +90,11 @@ for %%S in (!SOURCES!) do ( if /I "%%R"=="google" set "OUTPUT_FLAVOR=%%SFirebase" set "OUTPUT_DIR=%SCRIPT_DIR%app\build\outputs\apk\!OUTPUT_FLAVOR!\%%B" - echo Building !VARIANT_LABEL! - echo Gradle task: !TASK! - call "%SCRIPT_DIR%gradlew.bat" !TASK! + set "VERIFY_TASK=verify!SOURCE_CAP!!RUNTIME_CAP!!TYPE_CAP!BeforeAssemble" + + echo Verifying tests and building !VARIANT_LABEL! + echo Gradle tasks: !VERIFY_TASK! !TASK! + call "%SCRIPT_DIR%gradlew.bat" !VERIFY_TASK! !TASK! if errorlevel 1 ( echo Build failed. goto fail @@ -385,16 +387,16 @@ echo TDLib: %SOURCES% echo Runtime: %RUNTIMES% echo Build type: %BUILD_TYPES% echo ABI filter: %ABI_FILTER% -echo Gradle tasks: %TASK_COUNT% +echo Build variants: %TASK_COUNT% echo. exit /b 0 :print_usage echo Usage: -echo build-apk.bat -echo build-apk.bat [filters...] -echo build-apk.bat --help -echo build-apk.bat --list +echo build.bat +echo build.bat [filters...] +echo build.bat --help +echo build.bat --list echo. echo Interactive mode: echo 1. Select TDLib source: official / telemt / both @@ -424,15 +426,16 @@ echo -type-all Build debug and release echo -abi-all Show all generated APKs echo. echo Examples: -echo build-apk.bat -o -f -r -a64 -echo build-apk.bat -t -g -d -echo build-apk.bat -all-official -echo build-apk.bat -all +echo build.bat -o -f -r -a64 +echo build.bat -t -g -d +echo build.bat -all-official +echo build.bat -all echo. echo Notes: echo - If you pass filters, any group you do not specify defaults to "all". echo - The script validates the Android SDK path before starting Gradle. echo - Google builds require app\google-services.json. +echo - Unit-test verification runs before each APK assemble task. echo - ABI selection filters the APK files shown after the build. echo Gradle still produces the split outputs configured by the project. exit /b 0 diff --git a/build.gradle.kts b/build.gradle.kts index 2834a9b7d..e74822328 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,6 +19,51 @@ val localProperties by lazy { } extra.set("localProperties", localProperties) +val tdlibFlavors = listOf("Official", "Telemt") +val runtimeFlavors = listOf("Firebase", "Libre") +val buildTypes = listOf("Debug", "Release") + +data class AppAssemblyVariant( + val tdlib: String, + val runtime: String, + val buildType: String, +) { + val name: String get() = "$tdlib$runtime$buildType" + val unitTestBuildType: String get() = "Debug" + val unitTestVariantName: String get() = "$tdlib$runtime$unitTestBuildType" +} + +val appAssemblyVariants = + tdlibFlavors.flatMap { tdlib -> + runtimeFlavors.flatMap { runtime -> + buildTypes.map { buildType -> + AppAssemblyVariant( + tdlib = tdlib, + runtime = runtime, + buildType = buildType, + ) + } + } + } + +appAssemblyVariants.forEach { variant -> + val verifyTask = tasks.register("verify${variant.name}BeforeAssemble") { + group = "verification" + description = "Runs module unit tests before assembling the ${variant.name} APK." + dependsOn( + ":app:test${variant.unitTestVariantName}UnitTest", + ":data:test${variant.unitTestVariantName}UnitTest", + ":presentation:test${variant.unitTestVariantName}UnitTest", + ":core:test", + ":domain:test", + ) + } + + project(":app").tasks.matching { it.name == "assemble${variant.name}" }.configureEach { + dependsOn(verifyTask) + } +} + tasks.register("assembleOfficialReleaseTdlibApks") { group = "build" description = "Assembles release APKs with the official TDLib prebuilts." diff --git a/build.sh b/build.sh index e5ce66c48..5b103bda0 100644 --- a/build.sh +++ b/build.sh @@ -14,10 +14,10 @@ HAS_ARGS=0 print_usage() { cat <<'EOF' Usage: - ./build-apk.sh - ./build-apk.sh [filters...] - ./build-apk.sh --help - ./build-apk.sh --list + ./build.sh + ./build.sh [filters...] + ./build.sh --help + ./build.sh --list Interactive mode: 1. Select TDLib source: official / telemt / both @@ -47,15 +47,16 @@ Extra filters: -abi-all Show all generated APKs Examples: - ./build-apk.sh -o -f -r -a64 - ./build-apk.sh -t -g -d - ./build-apk.sh -all-official - ./build-apk.sh -all + ./build.sh -o -f -r -a64 + ./build.sh -t -g -d + ./build.sh -all-official + ./build.sh -all Notes: - If you pass filters, any group you do not specify defaults to "all". - The script validates the Android SDK path before starting Gradle. - Google builds require app/google-services.json. + - Unit-test verification runs before each APK assemble task. - ABI selection filters the APK files shown after the build. Gradle still produces the split outputs configured by the project. EOF @@ -354,6 +355,14 @@ build_task_for() { printf ':app:assemble%s%s%s' "$source_value" "$runtime_value" "$build_type_value" } +verify_task_for() { + source_value=$(source_capitalized "$1") + runtime_value=$(runtime_capitalized "$2") + build_type_value=$(build_type_capitalized "$3") + + printf 'verify%s%s%sBeforeAssemble' "$source_value" "$runtime_value" "$build_type_value" +} + variant_label_for() { printf '%s-%s-%s' "$1" "$2" "$3" } @@ -516,7 +525,7 @@ print_selection_summary() { echo " Runtime: $runtimes" echo " Build type: $build_types" echo " ABI filter: $abi_filter" - echo " Gradle tasks: $task_count" + echo " Build variants: $task_count" echo } @@ -591,11 +600,12 @@ for source_value in $SOURCES; do for runtime_value in $RUNTIMES; do for build_type_value in $BUILD_TYPES; do task=$(build_task_for "$source_value" "$runtime_value" "$build_type_value") + verify_task=$(verify_task_for "$source_value" "$runtime_value" "$build_type_value") label=$(variant_label_for "$source_value" "$runtime_value" "$build_type_value") - echo "Building $label" - echo "Gradle task: $task" - "$GRADLEW" "$task" + echo "Verifying tests and building $label" + echo "Gradle tasks: $verify_task $task" + "$GRADLEW" "$verify_task" "$task" echo print_artifacts_for_variant "$source_value" "$runtime_value" "$build_type_value" "$ABI_FILTER" From 6acfe5f1e127c394b0eaf9bd0e5341783dd0f97a Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:39:55 +0300 Subject: [PATCH 10/16] stories: Implement stealth mode, implement premium limits --- .../java/org/monogram/data/di/dataModule.kt | 3 +- .../data/repository/StoryRepositoryImpl.kt | 35 +++++- .../repository/StoryRepositoryStateReducer.kt | 9 ++ .../domain/models/stories/StoryModels.kt | 14 +++ .../domain/repository/StoryRepository.kt | 4 + .../stories/DefaultStoriesHostComponent.kt | 107 ++++++++++++++++- .../features/stories/StoriesHostComponent.kt | 7 ++ .../features/stories/StoriesHostContent.kt | 32 ++++- .../components/StoryViewerComponents.kt | 113 +++++++++++++++++- .../components/StoryViewerSceneComponents.kt | 3 +- .../src/main/res/values-es/string.xml | 17 +++ .../src/main/res/values-hy/string.xml | 17 +++ .../src/main/res/values-pt-rBR/string.xml | 17 +++ .../src/main/res/values-ru-rRU/string.xml | 17 +++ .../src/main/res/values-sk/string.xml | 17 +++ .../src/main/res/values-tr/string.xml | 17 +++ .../src/main/res/values-uk/string.xml | 17 +++ .../src/main/res/values-zh-rCN/string.xml | 17 +++ presentation/src/main/res/values/string.xml | 17 +++ 19 files changed, 469 insertions(+), 11 deletions(-) diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index 0f189bcdf..ca6ec4c59 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -839,7 +839,8 @@ val dataModule = module { gateway = get(), updates = get(), scope = get(), - fileDataSource = get() + fileDataSource = get(), + settingsRemoteDataSource = get() ) } diff --git a/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt index 321fdfdc0..80c525c50 100644 --- a/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.json.JSONObject import org.monogram.data.datasource.FileDataSource +import org.monogram.data.datasource.remote.SettingsRemoteDataSource import org.monogram.data.gateway.TelegramGateway import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.mapper.StoryInteractionMapper @@ -24,6 +25,7 @@ import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.models.stories.StoryMediaModel import org.monogram.domain.models.stories.StoryMediaType import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryOptionsModel import org.monogram.domain.models.stories.StoryPostCapabilityModel import org.monogram.domain.models.stories.StoryPostResultModel import org.monogram.domain.models.stories.StoryReactionModel @@ -35,7 +37,8 @@ class StoryRepositoryImpl( private val gateway: TelegramGateway, private val updates: UpdateDispatcher, private val scope: CoroutineScope, - private val fileDataSource: FileDataSource + private val fileDataSource: FileDataSource, + private val settingsRemoteDataSource: SettingsRemoteDataSource ) : StoryRepository { private val state = MutableStateFlow(StoryRepositoryState()) @@ -52,6 +55,9 @@ class StoryRepositoryImpl( private val _stealthMode = MutableStateFlow(StoryStealthModeModel()) override val stealthMode: StateFlow = _stealthMode.asStateFlow() + private val _storyOptions = MutableStateFlow(StoryOptionsModel()) + override val storyOptions: StateFlow = _storyOptions.asStateFlow() + private val _lastPostResult = MutableStateFlow(null) override val lastPostResult: StateFlow = _lastPostResult.asStateFlow() @@ -72,6 +78,19 @@ class StoryRepositoryImpl( } } + override suspend fun refreshStoryOptions() { + val options = StoryOptionsModel( + captionLengthMax = getIntegerOption("story_caption_length_max"), + linkAreaCountMax = getIntegerOption("story_link_area_count_max"), + stealthModeCooldownPeriod = getIntegerOption("story_stealth_mode_cooldown_period"), + stealthModeFuturePeriod = getIntegerOption("story_stealth_mode_future_period"), + stealthModePastPeriod = getIntegerOption("story_stealth_mode_past_period"), + suggestedReactionAreaCountMax = getIntegerOption("story_suggested_reaction_area_count_max"), + viewersExpirationDelay = getIntegerOption("story_viewers_expiration_delay") + ) + applyState(StoryRepositoryStateReducer.withStoryOptions(state.value, options)) + } + override suspend fun getChatActiveStories(chatId: Long): ActiveStoryListModel? { val existing = state.value.activeStories.values .asSequence() @@ -151,6 +170,13 @@ class StoryRepositoryImpl( runCatching { gateway.execute(TdApi.CloseStory(chatId, storyId)) } } + override suspend fun activateStealthMode(): Boolean { + return runCatching { + gateway.execute(TdApi.ActivateStoryStealthMode()) + true + }.getOrDefault(false) + } + override suspend fun canPostStory(chatId: Long): StoryPostCapabilityModel { return runCatching { StoryMapper.mapPostCapability(gateway.execute(TdApi.CanPostStory(chatId))) @@ -390,6 +416,7 @@ class StoryRepositoryImpl( _activeStories.value = newState.activeStories _storyListChatCounts.value = newState.storyListChatCounts _stealthMode.value = newState.stealthMode + _storyOptions.value = newState.storyOptions _lastPostResult.value = newState.lastPostResult Log.d( TAG, @@ -456,6 +483,12 @@ class StoryRepositoryImpl( } } + private suspend fun getIntegerOption(name: String): Int { + return (settingsRemoteDataSource.getOption(name) as? TdApi.OptionValueInteger)?.value + ?.toInt() + ?: 0 + } + private suspend fun resolveStoryMedia(content: TdApi.StoryContent): StoryMediaModel { return when (content) { is TdApi.StoryContentPhoto -> { diff --git a/data/src/main/java/org/monogram/data/repository/StoryRepositoryStateReducer.kt b/data/src/main/java/org/monogram/data/repository/StoryRepositoryStateReducer.kt index ee00752c2..4322598b0 100644 --- a/data/src/main/java/org/monogram/data/repository/StoryRepositoryStateReducer.kt +++ b/data/src/main/java/org/monogram/data/repository/StoryRepositoryStateReducer.kt @@ -3,6 +3,7 @@ package org.monogram.data.repository import org.monogram.domain.models.stories.ActiveStoryListModel import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryOptionsModel import org.monogram.domain.models.stories.StoryPostResultModel import org.monogram.domain.models.stories.StoryStealthModeModel @@ -16,6 +17,7 @@ internal data class StoryRepositoryState( val storyCache: Map = emptyMap(), val storyListChatCounts: Map = emptyMap(), val stealthMode: StoryStealthModeModel = StoryStealthModeModel(), + val storyOptions: StoryOptionsModel = StoryOptionsModel(), val lastPostResult: StoryPostResultModel? = null ) @@ -137,6 +139,13 @@ internal object StoryRepositoryStateReducer { return state.copy(stealthMode = stealthMode) } + fun withStoryOptions( + state: StoryRepositoryState, + storyOptions: StoryOptionsModel + ): StoryRepositoryState { + return state.copy(storyOptions = storyOptions) + } + fun clearLastPostResult(state: StoryRepositoryState): StoryRepositoryState { return state.copy(lastPostResult = null) } diff --git a/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt b/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt index 325ed61f1..f4fa67fa1 100644 --- a/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt +++ b/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt @@ -151,8 +151,22 @@ data class StoryStealthModeModel( ) { val isActive: Boolean get() = activeUntilDate > 0 + + fun isActiveAt(nowSeconds: Int): Boolean = activeUntilDate > nowSeconds + + fun isCoolingDownAt(nowSeconds: Int): Boolean = cooldownUntilDate > nowSeconds } +data class StoryOptionsModel( + val captionLengthMax: Int = 0, + val linkAreaCountMax: Int = 0, + val stealthModeCooldownPeriod: Int = 0, + val stealthModeFuturePeriod: Int = 0, + val stealthModePastPeriod: Int = 0, + val suggestedReactionAreaCountMax: Int = 0, + val viewersExpirationDelay: Int = 0 +) + data class StoryStatisticsModel( val storyInteractionGraph: StatisticsGraphModel, val storyReactionGraph: StatisticsGraphModel diff --git a/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt b/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt index e74cbbe87..9725d232f 100644 --- a/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt @@ -6,6 +6,7 @@ import org.monogram.domain.models.stories.StoryComposerDraftModel import org.monogram.domain.models.stories.StoryInteractionPageModel import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryOptionsModel import org.monogram.domain.models.stories.StoryPostCapabilityModel import org.monogram.domain.models.stories.StoryPostResultModel import org.monogram.domain.models.stories.StoryReactionModel @@ -16,9 +17,11 @@ interface StoryRepository { val activeStories: StateFlow>> val storyListChatCounts: StateFlow> val stealthMode: StateFlow + val storyOptions: StateFlow val lastPostResult: StateFlow suspend fun loadActiveStories(listType: StoryListType) + suspend fun refreshStoryOptions() suspend fun getChatActiveStories(chatId: Long): ActiveStoryListModel? suspend fun getStory(chatId: Long, storyId: Int, onlyLocal: Boolean = false): StoryModel? suspend fun getStoryAlbum( @@ -30,6 +33,7 @@ interface StoryRepository { suspend fun openStory(chatId: Long, storyId: Int) suspend fun closeStory(chatId: Long, storyId: Int) + suspend fun activateStealthMode(): Boolean suspend fun canPostStory(chatId: Long): StoryPostCapabilityModel suspend fun getStoryStatistics( chatId: Long, diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt index a9fe24879..ad2a15e44 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt @@ -15,6 +15,7 @@ import org.monogram.domain.models.stories.StoryInteractionPageModel import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.models.stories.StoryMediaType import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryOptionsModel import org.monogram.domain.models.stories.StoryPostResultModel import org.monogram.domain.models.stories.StoryPrivacyMode import org.monogram.domain.models.stories.StoryPrivacySettingsModel @@ -24,6 +25,7 @@ import org.monogram.domain.repository.AuthRepository import org.monogram.domain.repository.AuthStep import org.monogram.domain.repository.ChatListRepository import org.monogram.domain.repository.StoryRepository +import org.monogram.domain.repository.StringProvider import org.monogram.domain.repository.UserRepository import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -54,11 +56,29 @@ class DefaultStoriesHostComponent( override val state = _state.asStateFlow() init { + scope.launch { + userRepository.currentUserFlow.collect { user -> + _state.value = _state.value.copy( + currentUserId = user?.id, + isPremiumUser = user?.isPremium == true + ) + } + } scope.launch { appPreferences.storyMediaStretchEnabled.collect { enabled -> _state.value = _state.value.copy(isStoryMediaStretchEnabled = enabled) } } + scope.launch { + storyRepository.stealthMode.collect { stealthMode -> + _state.value = _state.value.copy(stealthMode = stealthMode) + } + } + scope.launch { + storyRepository.storyOptions.collect { storyOptions -> + _state.value = _state.value.copy(storyOptions = storyOptions) + } + } scope.launch { Log.d(TAG, "initializing stories host") authRepository.authState @@ -68,6 +88,7 @@ class DefaultStoriesHostComponent( if (!hasLoadedActiveStories) { hasLoadedActiveStories = true Log.d(TAG, "auth ready, loading active stories") + storyRepository.refreshStoryOptions() storyRepository.loadActiveStories(StoryListType.MAIN) storyRepository.loadActiveStories(StoryListType.ARCHIVE) } @@ -444,8 +465,14 @@ class DefaultStoriesHostComponent( override fun saveStory() { val current = _state.value val chatId = current.chatId ?: return - if (!current.composerDraft.isValid) { - _state.value = current.copy(inlineError = "Pick a photo or video first") + val validationError = resolveStorySaveValidationError( + stringProvider = stringProvider, + draft = current.composerDraft, + isPremiumUser = current.isPremiumUser, + storyOptions = current.storyOptions + ) + if (validationError != null) { + _state.value = current.copy(inlineError = validationError) return } @@ -458,6 +485,7 @@ class DefaultStoriesHostComponent( when ( val result = saveStoryDraft( storyRepository = storyRepository, + stringProvider = stringProvider, chatId = chatId, composerMode = current.composerMode, editingStoryId = current.editingStoryId, @@ -656,6 +684,38 @@ class DefaultStoriesHostComponent( } } + override fun activateStealthMode() { + val current = _state.value + val story = current.currentStory ?: return + val currentUserId = current.currentUserId + val nowSeconds = (System.currentTimeMillis() / 1000L).toInt() + if (!current.isPremiumUser || currentUserId == null || story.posterChatId == currentUserId) { + return + } + if ( + current.stealthMode.isActiveAt(nowSeconds) || + current.stealthMode.isCoolingDownAt(nowSeconds) + ) { + return + } + + scope.launch { + val activated = storyRepository.activateStealthMode() + if (!activated) { + _state.value = _state.value.copy( + inlineError = stringProvider.getString("story_stealth_mode_failed") + ) + } else { + messageDisplayer.show( + stringProvider.getString( + "story_stealth_mode_enabled", + stringProvider.getCompactStoryDuration(current.storyOptions.stealthModeFuturePeriod) + ) + ) + } + } + } + override fun openProfile(chatId: Long) { dismiss() onProfileClicked(chatId) @@ -966,6 +1026,10 @@ class DefaultStoriesHostComponent( private fun createDefaultState(): StoriesHostComponent.State { return StoriesHostComponent.State( + currentUserId = userRepository.currentUserFlow.value?.id, + isPremiumUser = userRepository.currentUserFlow.value?.isPremium == true, + stealthMode = storyRepository.stealthMode.value, + storyOptions = storyRepository.storyOptions.value, isStoryMediaStretchEnabled = appPreferences.storyMediaStretchEnabled.value ) } @@ -1065,6 +1129,7 @@ internal sealed class StorySaveOutcome { internal suspend fun saveStoryDraft( storyRepository: StoryRepository, + stringProvider: StringProvider, chatId: Long, composerMode: StoryComposerMode, editingStoryId: Int?, @@ -1074,7 +1139,7 @@ internal suspend fun saveStoryDraft( StoryComposerMode.CREATE -> { val mediaItems = draft.mediaItems if (mediaItems.isEmpty()) { - StorySaveOutcome.Failed("Pick a photo or video first") + StorySaveOutcome.Failed(stringProvider.getString("story_validation_pick_media")) } else { var lastStory: StoryModel? = null var createdCount = 0 @@ -1123,6 +1188,42 @@ internal suspend fun saveStoryDraft( } } +internal fun resolveStorySaveValidationError( + stringProvider: StringProvider, + draft: StoryComposerDraftModel, + isPremiumUser: Boolean, + storyOptions: StoryOptionsModel +): String? { + if (!draft.isValid) { + return stringProvider.getString("story_validation_pick_media") + } + + val captionLengthMax = storyOptions.captionLengthMax + if (captionLengthMax > 0 && draft.caption.length > captionLengthMax) { + return stringProvider.getString("story_validation_caption_too_long", captionLengthMax) + } + + if (!draft.widgetLink.isNullOrBlank() && (!isPremiumUser || storyOptions.linkAreaCountMax <= 0)) { + return stringProvider.getString("story_validation_links_premium") + } + + return null +} + +private fun StringProvider.getCompactStoryDuration(seconds: Int): String { + if (seconds <= 0) { + return getQuantityString("story_duration_compact_minutes", 0, 0) + } + + return if (seconds % 3600 == 0) { + val hours = seconds / 3600 + getQuantityString("story_duration_compact_hours", hours, hours) + } else { + val minutes = (seconds / 60).coerceAtLeast(1) + getQuantityString("story_duration_compact_minutes", minutes, minutes) + } +} + private fun inferStoryComposerMediaType(sourcePath: String?): StoryMediaType { val normalized = sourcePath.orEmpty().lowercase() return if ( diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt index afd932672..7d5b6fb68 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt @@ -8,9 +8,11 @@ import org.monogram.domain.models.stories.StoryInteractionPageModel import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.models.stories.StoryMediaType import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryOptionsModel import org.monogram.domain.models.stories.StoryPostCapabilityModel import org.monogram.domain.models.stories.StoryReactionModel import org.monogram.domain.models.stories.StoryStatisticsModel +import org.monogram.domain.models.stories.StoryStealthModeModel interface StoriesHostComponent { val state: StateFlow @@ -55,6 +57,7 @@ interface StoriesHostComponent { fun showStoryInteractions() fun dismissStoryInteractions() fun loadMoreStoryInteractions() + fun activateStealthMode() fun openProfile(chatId: Long) fun openStoryLink(url: String) fun copyStoryLink(url: String) @@ -70,10 +73,14 @@ interface StoriesHostComponent { val chatId: Long? = null, val chatTitle: String = "", val chatAvatarPath: String? = null, + val currentUserId: Long? = null, + val isPremiumUser: Boolean = false, val viewerItems: List = emptyList(), val viewerIndex: Int = 0, val currentStory: StoryModel? = null, val activeListType: StoryListType = StoryListType.MAIN, + val stealthMode: StoryStealthModeModel = StoryStealthModeModel(), + val storyOptions: StoryOptionsModel = StoryOptionsModel(), val canManageStories: Boolean = false, val composerMode: StoryComposerMode = StoryComposerMode.CREATE, val editingStoryId: Int? = null, diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt index f0b75151e..553ac0e73 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt @@ -68,6 +68,7 @@ import org.monogram.domain.models.stories.StoryMediaType import org.monogram.domain.models.stories.StoryModel import org.monogram.domain.models.stories.StoryPostCapabilityModel import org.monogram.domain.models.stories.StoryReactionModel +import org.monogram.domain.models.stories.StoryStealthModeModel import org.monogram.presentation.R import org.monogram.presentation.features.viewers.VideoViewer import java.util.Date @@ -221,7 +222,8 @@ internal fun StoryViewerChrome( onDelete: () -> Unit, onDownload: () -> Unit, onCopyMedia: () -> Unit, - onCopyStoryLink: () -> Unit + onCopyStoryLink: () -> Unit, + onActivateStealthMode: () -> Unit ) { StoryViewerChromeComponent( state = state, @@ -247,7 +249,8 @@ internal fun StoryViewerChrome( onDelete = onDelete, onDownload = onDownload, onCopyMedia = onCopyMedia, - onCopyStoryLink = onCopyStoryLink + onCopyStoryLink = onCopyStoryLink, + onActivateStealthMode = onActivateStealthMode ) } @@ -590,8 +593,33 @@ internal data class StoryCapabilityPresentation( val isBlocking: Boolean ) +internal enum class StoryStealthAvailability { + HIDDEN, + AVAILABLE, + ACTIVE, + COOLDOWN +} + internal const val STORY_MEDIA_ASPECT_RATIO = 9f / 16f +internal fun resolveStoryStealthAvailability( + isPremiumUser: Boolean, + currentUserId: Long?, + story: StoryModel?, + stealthMode: StoryStealthModeModel, + nowSeconds: Int +): StoryStealthAvailability { + if (!isPremiumUser || story == null || currentUserId == null || story.posterChatId == currentUserId) { + return StoryStealthAvailability.HIDDEN + } + + return when { + stealthMode.isActiveAt(nowSeconds) -> StoryStealthAvailability.ACTIVE + stealthMode.isCoolingDownAt(nowSeconds) -> StoryStealthAvailability.COOLDOWN + else -> StoryStealthAvailability.AVAILABLE + } +} + @Composable internal fun storyAreaPrimaryLabel(areaType: StoryAreaTypeModel): String { return when (areaType) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt index 2bc0a4fc3..b60aa2ac8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt @@ -41,6 +41,7 @@ import androidx.compose.material.icons.rounded.Pause import androidx.compose.material.icons.rounded.PeopleAlt import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Share +import androidx.compose.material.icons.rounded.VisibilityOff import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -58,6 +59,7 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -66,10 +68,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import kotlinx.coroutines.delay import org.monogram.domain.models.stories.StoryAreaTypeModel import org.monogram.domain.models.stories.StoryInteractionActorType import org.monogram.domain.models.stories.StoryInteractionPageModel @@ -105,7 +109,8 @@ internal fun StoryViewerChromeComponent( onDelete: () -> Unit, onDownload: () -> Unit, onCopyMedia: () -> Unit, - onCopyStoryLink: () -> Unit + onCopyStoryLink: () -> Unit, + onActivateStealthMode: () -> Unit ) { var showMenu by remember(story?.id, state.activeListType, state.canManageStories) { mutableStateOf(false) @@ -149,6 +154,10 @@ internal fun StoryViewerChromeComponent( StoryViewerActionsPopup( visible = showMenu, story = story, + currentUserId = state.currentUserId, + isPremiumUser = state.isPremiumUser, + stealthMode = state.stealthMode, + storyOptions = state.storyOptions, canManageStories = state.canManageStories, activeListType = state.activeListType, isMediaScaledToFill = isMediaScaledToFill, @@ -162,7 +171,8 @@ internal fun StoryViewerChromeComponent( onArchive = onArchive, onRestore = onRestore, onStatistics = onStatistics, - onDelete = onDelete + onDelete = onDelete, + onActivateStealthMode = onActivateStealthMode ) Column( @@ -411,6 +421,10 @@ private fun StoryViewerHeader( private fun StoryViewerActionsPopup( visible: Boolean, story: StoryModel?, + currentUserId: Long?, + isPremiumUser: Boolean, + stealthMode: org.monogram.domain.models.stories.StoryStealthModeModel, + storyOptions: org.monogram.domain.models.stories.StoryOptionsModel, canManageStories: Boolean, activeListType: StoryListType, isMediaScaledToFill: Boolean, @@ -424,10 +438,35 @@ private fun StoryViewerActionsPopup( onArchive: () -> Unit, onRestore: () -> Unit, onStatistics: () -> Unit, - onDelete: () -> Unit + onDelete: () -> Unit, + onActivateStealthMode: () -> Unit ) { if (story == null) return + val context = LocalContext.current + val nowSeconds by produceState(initialValue = (System.currentTimeMillis() / 1000L).toInt()) { + while (true) { + value = (System.currentTimeMillis() / 1000L).toInt() + delay(1_000) + } + } + val stealthAvailability = remember( + story.id, + story.posterChatId, + currentUserId, + isPremiumUser, + stealthMode.activeUntilDate, + stealthMode.cooldownUntilDate, + nowSeconds + ) { + resolveStoryStealthAvailability( + isPremiumUser = isPremiumUser, + currentUserId = currentUserId, + story = story, + stealthMode = stealthMode, + nowSeconds = nowSeconds + ) + } val menuActions = remember(story, canManageStories, activeListType) { buildStoryViewerMenuActions( story = story, @@ -450,6 +489,59 @@ private fun StoryViewerActionsPopup( onCheckedChange = onMediaScaleToggle ) + if (stealthAvailability != StoryStealthAvailability.HIDDEN) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) + ) + val remainingValue = when (stealthAvailability) { + StoryStealthAvailability.ACTIVE -> { + compactStoryDurationText( + (stealthMode.activeUntilDate - nowSeconds).coerceAtLeast(0) + ) + } + + StoryStealthAvailability.COOLDOWN -> { + compactStoryDurationText( + (stealthMode.cooldownUntilDate - nowSeconds).coerceAtLeast(0) + ) + } + + else -> null + } + val subtitle = when (stealthAvailability) { + StoryStealthAvailability.AVAILABLE -> { + val past = compactStoryDurationText(storyOptions.stealthModePastPeriod) + val future = compactStoryDurationText(storyOptions.stealthModeFuturePeriod) + stringResource(R.string.story_stealth_mode_available, past, future) + } + + StoryStealthAvailability.ACTIVE -> { + stringResource(R.string.story_stealth_mode_active) + } + + StoryStealthAvailability.COOLDOWN -> { + stringResource( + R.string.story_stealth_mode_cooldown_until, + formatStoryPostedTime(context, stealthMode.cooldownUntilDate) + ) + } + + StoryStealthAvailability.HIDDEN -> null + } + MenuOptionRow( + icon = Icons.Rounded.VisibilityOff, + title = stringResource(R.string.story_stealth_mode_title), + subtitle = subtitle, + value = remainingValue, + enabled = stealthAvailability == StoryStealthAvailability.AVAILABLE, + onClick = { + onDismiss() + onActivateStealthMode() + } + ) + } + if (menuActions.isNotEmpty()) { HorizontalDivider( modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), @@ -487,6 +579,21 @@ private fun StoryViewerActionsPopup( } } +@Composable +private fun compactStoryDurationText(seconds: Int): String { + if (seconds <= 0) { + return pluralStringResource(R.plurals.story_duration_compact_minutes, 0, 0) + } + + return if (seconds % 3600 == 0) { + val hours = seconds / 3600 + pluralStringResource(R.plurals.story_duration_compact_hours, hours, hours) + } else { + val minutes = (seconds / 60).coerceAtLeast(1) + pluralStringResource(R.plurals.story_duration_compact_minutes, minutes, minutes) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun StoryLinksSheetComponent( diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt index 934948776..e6ab11e9f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt @@ -301,7 +301,8 @@ internal fun StoryViewerScaffoldComponent( } } }, - onCopyStoryLink = component::copyCurrentStoryLink + onCopyStoryLink = component::copyCurrentStoryLink, + onActivateStealthMode = component::activateStealthMode ) if (isLinksSheetVisible && !story?.linkUrls.isNullOrEmpty()) { diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 06aed0e6f..58588e45a 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -2482,4 +2482,21 @@ Suscribirse Suscribiéndose… %1$d / %2$d + Modo sigiloso + Hace %1$s • Próximos %2$s + Visualizaciones ocultas + Disponible de nuevo a las %1$s + Modo sigiloso activo durante %1$s + No se pudo activar el modo sigiloso + Primero elige una foto o un video + La descripción de la historia es demasiado larga. El máximo es de %1$d caracteres + Los enlaces en historias requieren Telegram Premium + + %1$dh + %1$dh + + + %1$d min + %1$d min + diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index c5d9cfde9..264f5d307 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -2365,4 +2365,21 @@ Բաժանորդագրվել Բաժանորդագրվում է… %1$d / %2$d + Անտեսանելի ռեժիմ + անցած %1$s • առաջիկա %2$s + Դիտումները թաքցված են + Կրկին հասանելի կլինի %1$s-ին + Անտեսանելի ռեժիմը միացված է %1$s + Չհաջողվեց միացնել անտեսանելի ռեժիմը + Նախ ընտրեք լուսանկար կամ տեսանյութ + Պատմության նկարագրությունը չափազանց երկար է։ Առավելագույնը՝ %1$d նիշ + Պատմություններում հղումները հասանելի են միայն Telegram Premium-ով + + %1$d ժ + %1$d ժ + + + %1$d ր + %1$d ր + diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 8413cafc0..f57bf731d 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -2489,4 +2489,21 @@ Inscrever-se Inscrevendo-se… %1$d / %2$d + Modo furtivo + %1$s atrás • %2$s à frente + Visualizações ocultas + Disponível novamente às %1$s + Modo furtivo ativado por %1$s + Não foi possível ativar o modo furtivo + Escolha uma foto ou vídeo primeiro + A legenda do story é muito longa. O máximo é %1$d caracteres + Links em stories exigem Telegram Premium + + %1$dh + %1$dh + + + %1$dmin + %1$dmin + diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 1762fcd67..9e9f5b8fa 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -2479,4 +2479,21 @@ Копировать кадр Не удалось открыть статью %1$d / %2$d + Стелс-режим + %1$s назад • %2$s вперёд + Просмотры скрыты + Снова доступен в %1$s + Стелс-режим включён на %1$s + Не удалось включить стелс-режим + Сначала выберите фото или видео + Подпись к истории слишком длинная. Максимум: %1$d символов + Ссылки в историях доступны только в Telegram Premium + + %1$dч + %1$dч + + + %1$dм + %1$dм + diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 6ce369b3d..7a5368139 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -2611,4 +2611,21 @@ Prihlásiť sa na odber Prihlasovanie na odber… %1$d / %2$d + Utajený režim + %1$s dozadu • %2$s dopredu + Zobrazenia sú skryté + Znova dostupné o %1$s + Utajený režim je zapnutý na %1$s + Utajený režim sa nepodarilo zapnúť + Najprv vyberte fotku alebo video + Popis príbehu je príliš dlhý. Maximum je %1$d znakov + Odkazy v príbehoch vyžadujú Telegram Premium + + %1$dh + %1$dh + + + %1$d min + %1$d min + diff --git a/presentation/src/main/res/values-tr/string.xml b/presentation/src/main/res/values-tr/string.xml index fc5973c1c..84abe283d 100644 --- a/presentation/src/main/res/values-tr/string.xml +++ b/presentation/src/main/res/values-tr/string.xml @@ -2459,4 +2459,21 @@ Abone ol Abone olunuyor… %1$d / %2$d + Gizli mod + %1$s geri • %2$s ileri + Görüntülemeler gizlendi + %1$s saatinde yeniden kullanılabilir + Gizli mod %1$s boyunca açık + Gizli mod etkinleştirilemedi + Önce bir fotoğraf veya video seçin + Hikaye açıklaması çok uzun. En fazla %1$d karakter olabilir + Hikayelerde bağlantılar için Telegram Premium gerekir + + %1$dsa + %1$dsa + + + %1$ddk + %1$ddk + diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index e719e7845..478848427 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -2478,4 +2478,21 @@ Підписатися Підписуємо… %1$d / %2$d + Режим непомітності + %1$s назад • %2$s вперед + Перегляди приховано + Знову буде доступно о %1$s + Режим непомітності увімкнено на %1$s + Не вдалося увімкнути режим непомітності + Спочатку виберіть фото або відео + Підпис до історії надто довгий. Максимум: %1$d символів + Посилання в історіях доступні лише в Telegram Premium + + %1$dгод + %1$dгод + + + %1$dхв + %1$dхв + diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index a89271ffd..6e65b3229 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -2457,4 +2457,21 @@ 订阅 订阅中… %1$d / %2$d + 隐身模式 + 回溯 %1$s • 未来 %2$s + 浏览已隐藏 + %1$s 后可再次使用 + 隐身模式已开启,持续 %1$s + 无法开启隐身模式 + 请先选择照片或视频 + 故事说明太长,最多 %1$d 个字符 + 故事中的链接需要 Telegram Premium + + %1$d小时 + %1$d小时 + + + %1$d分 + %1$d分 + diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index e00096bdd..0695d1c7e 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -631,6 +631,12 @@ Story Statistics Views and Shares Reactions + Stealth mode + %1$s back • %2$s ahead + Views hidden + Available again at %1$s + Stealth mode on for %1$s + Failed to enable stealth mode Story Interactions No interactions yet Load more @@ -2630,4 +2636,15 @@ Later Subscribe Subscribing... + Pick a photo or video first + Story caption is too long. Maximum is %1$d characters + Story links require Telegram Premium + + %1$dh + %1$dh + + + %1$dm + %1$dm + From 08c6e19efccc55b2540c4c48e0003c204e815524 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:15:20 +0300 Subject: [PATCH 11/16] stories: Implement reactions and overlay ui --- .../data/mapper/StoryInteractionMapper.kt | 11 +- .../org/monogram/data/mapper/StoryMapper.kt | 49 ++ .../data/repository/StoryRepositoryImpl.kt | 10 + .../domain/models/stories/StoryModels.kt | 28 +- .../domain/repository/StoryRepository.kt | 2 + .../stickers/ui/menu/MenuComponents.kt | 22 +- .../stories/DefaultStoriesHostComponent.kt | 66 ++- .../features/stories/StoriesHostComponent.kt | 6 + .../features/stories/StoriesHostContent.kt | 67 ++- .../components/StoryViewerComponents.kt | 539 +++++++++++++++++- .../components/StoryViewerSceneComponents.kt | 145 +++-- .../src/main/res/values-ru-rRU/string.xml | 13 + presentation/src/main/res/values/string.xml | 13 + 13 files changed, 868 insertions(+), 103 deletions(-) diff --git a/data/src/main/java/org/monogram/data/mapper/StoryInteractionMapper.kt b/data/src/main/java/org/monogram/data/mapper/StoryInteractionMapper.kt index efb74e536..cad2a6668 100644 --- a/data/src/main/java/org/monogram/data/mapper/StoryInteractionMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/StoryInteractionMapper.kt @@ -43,7 +43,7 @@ object StoryInteractionMapper { actorType = actorType, interactionDate = interaction.interactionDate, type = StoryInteractionTypeModel.VIEW, - reaction = type.chosenReactionType.toReactionLabel() + reaction = StoryMapper.mapReactionType(type.chosenReactionType) ) is TdApi.StoryInteractionTypeForward -> StoryInteractionModel( @@ -71,13 +71,4 @@ object StoryInteractionMapper { ) } } - - private fun TdApi.ReactionType?.toReactionLabel(): String? { - return when (this) { - is TdApi.ReactionTypeEmoji -> emoji - is TdApi.ReactionTypeCustomEmoji -> "custom:$customEmojiId" - is TdApi.ReactionTypePaid -> "paid" - else -> null - } - } } diff --git a/data/src/main/java/org/monogram/data/mapper/StoryMapper.kt b/data/src/main/java/org/monogram/data/mapper/StoryMapper.kt index e48d52b94..708362048 100644 --- a/data/src/main/java/org/monogram/data/mapper/StoryMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/StoryMapper.kt @@ -5,6 +5,8 @@ import org.monogram.domain.models.stories.ActiveStoryListModel import org.monogram.domain.models.stories.StoryAreaModel import org.monogram.domain.models.stories.StoryAreaPositionModel import org.monogram.domain.models.stories.StoryAreaTypeModel +import org.monogram.domain.models.stories.StoryAvailableReactionModel +import org.monogram.domain.models.stories.StoryAvailableReactionsModel import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.models.stories.StoryMediaModel import org.monogram.domain.models.stories.StoryMediaType @@ -13,6 +15,7 @@ import org.monogram.domain.models.stories.StoryPostCapabilityModel import org.monogram.domain.models.stories.StoryPrivacyMode import org.monogram.domain.models.stories.StoryPrivacySettingsModel import org.monogram.domain.models.stories.StoryReactionModel +import org.monogram.domain.models.stories.StoryReactionUnavailabilityReasonModel import org.monogram.domain.models.stories.StoryStealthModeModel import org.monogram.domain.models.stories.StorySummaryModel @@ -49,6 +52,7 @@ object StoryMapper { date = story.date, caption = story.caption?.text.orEmpty(), media = mediaOverride ?: story.content.toDomainMedia(), + chosenReaction = story.chosenReactionType?.toDomainReaction(), privacy = story.privacySettings.toDomainPrivacy(), albumIds = story.albumIds?.toList().orEmpty(), areas = story.areas.orEmpty().mapNotNull(::mapStoryArea), @@ -80,6 +84,19 @@ object StoryMapper { ) } + fun mapAvailableReactions(availableReactions: TdApi.AvailableReactions): StoryAvailableReactionsModel { + return StoryAvailableReactionsModel( + topReactions = availableReactions.topReactions.orEmpty() + .mapNotNull(::mapAvailableReaction), + recentReactions = availableReactions.recentReactions.orEmpty() + .mapNotNull(::mapAvailableReaction), + popularReactions = availableReactions.popularReactions.orEmpty() + .mapNotNull(::mapAvailableReaction), + allowCustomEmoji = availableReactions.allowCustomEmoji, + unavailabilityReason = mapReactionUnavailabilityReason(availableReactions.unavailabilityReason) + ) + } + fun mapPostCapability(result: TdApi.CanPostStoryResult?): StoryPostCapabilityModel { return when (result) { is TdApi.CanPostStoryResultOk -> StoryPostCapabilityModel.Allowed(result.storyCount) @@ -209,6 +226,38 @@ object StoryMapper { } } + fun mapReactionType(reactionType: TdApi.ReactionType?): StoryReactionModel { + return reactionType.toDomainReaction() + } + + private fun mapAvailableReaction(reaction: TdApi.AvailableReaction?): StoryAvailableReactionModel? { + reaction ?: return null + return StoryAvailableReactionModel( + reaction = reaction.type.toDomainReaction(), + needsPremium = reaction.needsPremium + ) + } + + private fun mapReactionUnavailabilityReason( + reason: TdApi.ReactionUnavailabilityReason? + ): StoryReactionUnavailabilityReasonModel? { + return when (reason) { + is TdApi.ReactionUnavailabilityReasonAnonymousAdministrator -> { + StoryReactionUnavailabilityReasonModel.ANONYMOUS_ADMINISTRATOR + } + + is TdApi.ReactionUnavailabilityReasonGuest -> { + StoryReactionUnavailabilityReasonModel.GUEST + } + + is TdApi.ReactionUnavailabilityReasonRestricted -> { + StoryReactionUnavailabilityReasonModel.RESTRICTED + } + + else -> null + } + } + private fun TdApi.ReactionType?.toDomainReaction(): StoryReactionModel { return when (this) { is TdApi.ReactionTypeEmoji -> StoryReactionModel(emoji = emoji) diff --git a/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt index 80c525c50..e5361b333 100644 --- a/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt @@ -19,6 +19,7 @@ import org.monogram.data.mapper.StoryMapper.toDomainStoryListType import org.monogram.data.mapper.StoryMapper.toTdPrivacy import org.monogram.data.mapper.StoryMapper.toTdStoryList import org.monogram.domain.models.stories.ActiveStoryListModel +import org.monogram.domain.models.stories.StoryAvailableReactionsModel import org.monogram.domain.models.stories.StoryComposerDraftModel import org.monogram.domain.models.stories.StoryInteractionPageModel import org.monogram.domain.models.stories.StoryListType @@ -195,11 +196,20 @@ class StoryRepositoryImpl( }.getOrNull() } + override suspend fun getStoryAvailableReactions(rowSize: Int): StoryAvailableReactionsModel? { + return runCatching { + StoryMapper.mapAvailableReactions( + gateway.execute(TdApi.GetStoryAvailableReactions(rowSize.coerceIn(5, 25))) + ) + }.getOrNull() + } + override suspend fun setStoryReaction( chatId: Long, storyId: Int, reaction: StoryReactionModel ): Boolean { + if (reaction.isPaid) return false return runCatching { gateway.execute( TdApi.SetStoryReaction( diff --git a/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt b/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt index f4fa67fa1..15b920d91 100644 --- a/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt +++ b/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt @@ -25,6 +25,7 @@ data class StoryModel( val date: Int, val caption: String, val media: StoryMediaModel, + val chosenReaction: StoryReactionModel? = null, val privacy: StoryPrivacySettingsModel, val albumIds: List = emptyList(), val areas: List = emptyList(), @@ -90,6 +91,31 @@ data class StoryReactionModel( get() = customEmojiId != null } +data class StoryAvailableReactionModel( + val reaction: StoryReactionModel, + val needsPremium: Boolean = false +) + +data class StoryAvailableReactionsModel( + val topReactions: List = emptyList(), + val recentReactions: List = emptyList(), + val popularReactions: List = emptyList(), + val allowCustomEmoji: Boolean = false, + val unavailabilityReason: StoryReactionUnavailabilityReasonModel? = null +) { + val hasAnyReactionOption: Boolean + get() = topReactions.isNotEmpty() || + recentReactions.isNotEmpty() || + popularReactions.isNotEmpty() || + allowCustomEmoji +} + +enum class StoryReactionUnavailabilityReasonModel { + ANONYMOUS_ADMINISTRATOR, + GUEST, + RESTRICTED +} + data class StoryMediaModel( val type: StoryMediaType, val path: String?, @@ -177,7 +203,7 @@ data class StoryInteractionModel( val actorType: StoryInteractionActorType, val interactionDate: Int, val type: StoryInteractionTypeModel, - val reaction: String? = null, + val reaction: StoryReactionModel? = null, val forwardChatId: Long? = null, val forwardMessageId: Long? = null, val repostStoryId: Int? = null, diff --git a/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt b/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt index 9725d232f..2ee0a875d 100644 --- a/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt @@ -2,6 +2,7 @@ package org.monogram.domain.repository import kotlinx.coroutines.flow.StateFlow import org.monogram.domain.models.stories.ActiveStoryListModel +import org.monogram.domain.models.stories.StoryAvailableReactionsModel import org.monogram.domain.models.stories.StoryComposerDraftModel import org.monogram.domain.models.stories.StoryInteractionPageModel import org.monogram.domain.models.stories.StoryListType @@ -40,6 +41,7 @@ interface StoryRepository { storyId: Int, isDark: Boolean ): StoryStatisticsModel? + suspend fun getStoryAvailableReactions(rowSize: Int = 8): StoryAvailableReactionsModel? suspend fun setStoryReaction( chatId: Long, diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MenuComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MenuComponents.kt index c3ff940d7..5a66cf5aa 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MenuComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MenuComponents.kt @@ -290,17 +290,25 @@ fun MenuToggleRow( modifier = Modifier .fillMaxWidth() .clickable { onCheckedChange(!isChecked) } - .padding(horizontal = 16.dp, vertical = 4.dp), + .padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(22.dp) - ) + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.size(34.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp) + ) + } + } Spacer(modifier = Modifier.width(12.dp)) Text( text = title, diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt index ad2a15e44..2888e7c16 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt @@ -126,6 +126,8 @@ class DefaultStoriesHostComponent( isStoryInteractionsVisible = false, isStoryInteractionsLoading = false, storyInteractionsPage = null, + isStoryReactionPickerVisible = false, + isStoryReactionPickerLoading = false, showInlineVideo = false, showStoryMediaLoadingMessage = false ) @@ -206,6 +208,8 @@ class DefaultStoriesHostComponent( isStoryInteractionsVisible = false, isStoryInteractionsLoading = false, storyInteractionsPage = null, + isStoryReactionPickerVisible = false, + isStoryReactionPickerLoading = false, showInlineVideo = false, showStoryMediaLoadingMessage = false ) @@ -307,6 +311,8 @@ class DefaultStoriesHostComponent( isStoryInteractionsVisible = false, isStoryInteractionsLoading = false, storyInteractionsPage = null, + isStoryReactionPickerVisible = false, + isStoryReactionPickerLoading = false, showMediaPicker = !draft.isValid, showCamera = false, showInlineVideo = false, @@ -649,7 +655,9 @@ class DefaultStoriesHostComponent( _state.value = _state.value.copy( isStoryInteractionsVisible = false, isStoryInteractionsLoading = false, - storyInteractionsPage = null + storyInteractionsPage = null, + isStoryReactionPickerVisible = false, + isStoryReactionPickerLoading = false ) } @@ -684,6 +692,52 @@ class DefaultStoriesHostComponent( } } + override fun showStoryReactionPicker() { + val current = _state.value + val story = current.currentStory ?: return + if (current.currentUserId == story.posterChatId) return + + val cached = current.storyAvailableReactions + if (cached?.hasAnyReactionOption == true) { + _state.value = current.copy( + isStoryReactionPickerVisible = true, + isStoryReactionPickerLoading = false, + inlineError = null + ) + return + } + + _state.value = current.copy( + isStoryReactionPickerVisible = true, + isStoryReactionPickerLoading = true, + inlineError = null + ) + scope.launch { + val availableReactions = storyRepository.getStoryAvailableReactions() + if (availableReactions == null) { + _state.value = _state.value.copy( + isStoryReactionPickerVisible = false, + isStoryReactionPickerLoading = false, + inlineError = "Failed to load story reactions" + ) + } else { + _state.value = _state.value.copy( + isStoryReactionPickerVisible = true, + isStoryReactionPickerLoading = false, + storyAvailableReactions = availableReactions, + inlineError = null + ) + } + } + } + + override fun dismissStoryReactionPicker() { + _state.value = _state.value.copy( + isStoryReactionPickerVisible = false, + isStoryReactionPickerLoading = false + ) + } + override fun activateStealthMode() { val current = _state.value val story = current.currentStory ?: return @@ -754,6 +808,12 @@ class DefaultStoriesHostComponent( ) if (!success) { _state.value = _state.value.copy(inlineError = "Failed to send story reaction") + } else { + _state.value = _state.value.copy( + inlineError = null, + isStoryReactionPickerVisible = false, + isStoryReactionPickerLoading = false + ) } } } @@ -796,6 +856,8 @@ class DefaultStoriesHostComponent( isStoryInteractionsVisible = false, isStoryInteractionsLoading = false, storyInteractionsPage = null, + isStoryReactionPickerVisible = false, + isStoryReactionPickerLoading = false, showInlineVideo = false, showStoryMediaLoadingMessage = false ) @@ -1100,6 +1162,8 @@ private fun restoreViewerState(state: StoriesHostComponent.State): StoriesHostCo isStoryInteractionsVisible = false, isStoryInteractionsLoading = false, storyInteractionsPage = null, + isStoryReactionPickerVisible = false, + isStoryReactionPickerLoading = false, showMediaPicker = false, showCamera = false ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt index 7d5b6fb68..5550bb449 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt @@ -2,6 +2,7 @@ package org.monogram.presentation.features.stories import kotlinx.coroutines.flow.StateFlow import org.monogram.domain.models.stories.ActiveStoryListModel +import org.monogram.domain.models.stories.StoryAvailableReactionsModel import org.monogram.domain.models.stories.StoryComposerDraftModel import org.monogram.domain.models.stories.StoryComposerMediaItemModel import org.monogram.domain.models.stories.StoryInteractionPageModel @@ -57,6 +58,8 @@ interface StoriesHostComponent { fun showStoryInteractions() fun dismissStoryInteractions() fun loadMoreStoryInteractions() + fun showStoryReactionPicker() + fun dismissStoryReactionPicker() fun activateStealthMode() fun openProfile(chatId: Long) fun openStoryLink(url: String) @@ -94,6 +97,9 @@ interface StoriesHostComponent { val isStoryInteractionsVisible: Boolean = false, val isStoryInteractionsLoading: Boolean = false, val storyInteractionsPage: StoryInteractionPageModel? = null, + val isStoryReactionPickerVisible: Boolean = false, + val isStoryReactionPickerLoading: Boolean = false, + val storyAvailableReactions: StoryAvailableReactionsModel? = null, val showMediaPicker: Boolean = false, val showCamera: Boolean = false, val showInlineVideo: Boolean = false, diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt index 553ac0e73..de61c8774 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt @@ -61,6 +61,8 @@ import androidx.compose.ui.zIndex import androidx.core.view.WindowCompat import coil3.compose.AsyncImage import org.monogram.domain.models.stories.StoryAreaTypeModel +import org.monogram.domain.models.stories.StoryAvailableReactionModel +import org.monogram.domain.models.stories.StoryAvailableReactionsModel import org.monogram.domain.models.stories.StoryInteractionPageModel import org.monogram.domain.models.stories.StoryInteractionTypeModel import org.monogram.domain.models.stories.StoryListType @@ -85,6 +87,7 @@ fun StoriesHostContent(component: StoriesHostComponent) { state.showInlineVideo -> component.dismissInlineVideo() state.showCamera -> component.dismissCamera() state.showMediaPicker -> component.dismissMediaPicker() + state.isStoryReactionPickerVisible -> component.dismissStoryReactionPicker() else -> component.dismiss() } } @@ -288,6 +291,26 @@ internal fun StoryInteractionsSheet( ) } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun StoryReactionPickerSheet( + availableReactions: StoryAvailableReactionsModel?, + selectedReaction: StoryReactionModel?, + isLoading: Boolean, + isPremiumUser: Boolean, + onDismiss: () -> Unit, + onReactionSelected: (StoryReactionModel) -> Unit +) { + StoryReactionPickerSheetComponent( + availableReactions = availableReactions, + selectedReaction = selectedReaction, + isLoading = isLoading, + isPremiumUser = isPremiumUser, + onDismiss = onDismiss, + onReactionSelected = onReactionSelected + ) +} + @Composable internal fun StoryInteractionAvatar( avatarPath: String?, @@ -837,18 +860,54 @@ internal fun storyViewerMenuIcon( @Composable internal fun storyInteractionTypeLabel( type: StoryInteractionTypeModel, - reaction: String? + reaction: StoryReactionModel? ): String { return when (type) { - StoryInteractionTypeModel.VIEW -> reaction?.takeIf { it.isNotBlank() }?.let { - stringResource(R.string.story_interaction_view_with_reaction, it) - } ?: stringResource(R.string.story_interaction_view) + StoryInteractionTypeModel.VIEW -> when { + reaction == null -> stringResource(R.string.story_interaction_view) + reaction.emoji != null || reaction.isPaid -> { + stringResource( + R.string.story_interaction_view_with_reaction, + storyReactionLabel(reaction) + ) + } + + else -> stringResource(R.string.story_interaction_view_with_reaction_generic) + } StoryInteractionTypeModel.FORWARD -> stringResource(R.string.story_interaction_forward) StoryInteractionTypeModel.REPOST -> stringResource(R.string.story_interaction_repost) } } +internal data class StoryReactionSectionModel( + val titleResId: Int, + val reactions: List +) + +internal fun buildStoryReactionSections( + availableReactions: StoryAvailableReactionsModel? +): List { + availableReactions ?: return emptyList() + val consumed = linkedSetOf() + + fun unique(source: List): List { + return source.filter { item -> consumed.add(item.reaction) } + } + + return buildList { + unique(availableReactions.topReactions) + .takeIf { it.isNotEmpty() } + ?.let { add(StoryReactionSectionModel(R.string.story_reaction_picker_top, it)) } + unique(availableReactions.recentReactions) + .takeIf { it.isNotEmpty() } + ?.let { add(StoryReactionSectionModel(R.string.story_reaction_picker_recent, it)) } + unique(availableReactions.popularReactions) + .takeIf { it.isNotEmpty() } + ?.let { add(StoryReactionSectionModel(R.string.story_reaction_picker_popular, it)) } + } +} + internal fun buildStoryPositionText(state: StoriesHostComponent.State): String { val currentChatId = state.chatId ?: return "" val totalInChat = state.viewerItems.count { it.chatId == currentChatId } diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt index b60aa2ac8..bccdfde3e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt @@ -2,6 +2,7 @@ package org.monogram.presentation.features.stories import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically @@ -10,6 +11,7 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -26,13 +28,16 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.VolumeOff import androidx.compose.material.icons.automirrored.rounded.VolumeUp import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.EmojiEmotions import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.Image import androidx.compose.material.icons.rounded.Link @@ -57,10 +62,12 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -70,19 +77,28 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import kotlinx.coroutines.delay -import org.monogram.domain.models.stories.StoryAreaTypeModel +import org.koin.compose.koinInject +import org.monogram.domain.models.stories.StoryAvailableReactionModel +import org.monogram.domain.models.stories.StoryAvailableReactionsModel import org.monogram.domain.models.stories.StoryInteractionActorType import org.monogram.domain.models.stories.StoryInteractionPageModel import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryReactionModel +import org.monogram.domain.models.stories.StoryReactionUnavailabilityReasonModel +import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.R import org.monogram.presentation.features.stickers.ui.menu.ActionMenuPopup import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow import org.monogram.presentation.features.stickers.ui.menu.MenuToggleRow +import org.monogram.presentation.features.stickers.ui.menu.StickerEmojiMenu +import org.monogram.presentation.features.stickers.ui.view.StickerImage @Composable internal fun StoryViewerChromeComponent( @@ -232,9 +248,10 @@ internal fun StoryViewerChromeComponent( } AnimatedVisibility( - visible = story?.canGetInteractions == true || story?.areas?.any { - it.type is StoryAreaTypeModel.SuggestedReaction - } == true + visible = story != null && ( + story.canGetInteractions || + state.currentUserId != null && state.currentUserId != story.posterChatId + ) ) { StoryTopIconButton( onClick = onReactionClick, @@ -777,24 +794,16 @@ internal fun StoryInteractionsSheetComponent( ) }, supportingContent = { - Text( - text = buildString { - append( - storyInteractionTypeLabel( - interaction.type, - interaction.reaction - ) - ) - append(" • ") - append( - formatStoryPostedTime( - context, - interaction.interactionDate - ) - ) - }, - maxLines = 2, - overflow = TextOverflow.Ellipsis + StoryInteractionSupportingContent( + reaction = interaction.reaction, + label = storyInteractionTypeLabel( + interaction.type, + interaction.reaction + ), + postedAt = formatStoryPostedTime( + context, + interaction.interactionDate + ) ) } ) @@ -830,3 +839,489 @@ internal fun StoryInteractionsSheetComponent( } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun StoryReactionPickerSheetComponent( + availableReactions: StoryAvailableReactionsModel?, + selectedReaction: StoryReactionModel?, + isLoading: Boolean, + isPremiumUser: Boolean, + onDismiss: () -> Unit, + onReactionSelected: (StoryReactionModel) -> Unit +) { + val stickerRepository: StickerRepository = koinInject() + var showEmojiBrowser by rememberSaveable { mutableStateOf(false) } + val sections = remember(availableReactions) { buildStoryReactionSections(availableReactions) } + val unavailabilityReason = availableReactions?.unavailabilityReason + val canSelectReactions = unavailabilityReason == null + val canOpenCustomEmoji = availableReactions?.allowCustomEmoji == true && + isPremiumUser && + canSelectReactions + + if (showEmojiBrowser) { + ModalBottomSheet( + onDismissRequest = { showEmojiBrowser = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + dragHandle = { BottomSheetDefaults.DragHandle() }, + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) + ) { + StickerEmojiMenu( + onStickerSelected = {}, + onEmojiSelected = { emoji, sticker -> + val reaction = sticker?.customEmojiId + ?.takeIf { it != 0L } + ?.let { StoryReactionModel(customEmojiId = it) } + ?: StoryReactionModel(emoji = emoji) + onReactionSelected(reaction) + }, + onGifSelected = {}, + emojiOnlyMode = true, + onSearchFocused = {}, + canSendStickers = false, + stickerRepository = stickerRepository + ) + } + return + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + dragHandle = { BottomSheetDefaults.DragHandle() }, + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .verticalScroll(rememberScrollState()) + .padding(bottom = 32.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(R.string.story_reaction_picker_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + + if (availableReactions?.allowCustomEmoji == true) { + StoryReactionPickerCustomEmojiButton( + enabled = canOpenCustomEmoji, + isPremiumUser = isPremiumUser, + selectedReaction = selectedReaction, + onClick = { showEmojiBrowser = true } + ) + Spacer(modifier = Modifier.height(14.dp)) + } + + if (unavailabilityReason != null) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.72f) + ) { + Text( + text = storyReactionUnavailabilityLabel(unavailabilityReason), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + Spacer(modifier = Modifier.height(14.dp)) + } + + when { + isLoading && availableReactions == null -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(220.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + sections.isEmpty() && availableReactions?.allowCustomEmoji != true -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(160.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.story_reaction_picker_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + else -> { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + sections.forEach { section -> + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 12.dp) + ) { + Text( + text = stringResource(section.titleResId), + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Start + ) + Spacer(modifier = Modifier.height(12.dp)) + StoryReactionPickerGrid( + reactions = section.reactions, + selectedReaction = selectedReaction, + isPremiumUser = isPremiumUser, + canSelectReactions = canSelectReactions, + onReactionSelected = onReactionSelected + ) + } + } + } + } + } + } + } + } +} + +@Composable +private fun StoryReactionPickerGrid( + reactions: List, + selectedReaction: StoryReactionModel?, + isPremiumUser: Boolean, + canSelectReactions: Boolean, + onReactionSelected: (StoryReactionModel) -> Unit +) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val columns = when { + maxWidth >= 560.dp -> 6 + maxWidth >= 440.dp -> 5 + maxWidth >= 320.dp -> 4 + else -> 3 + } + val rows = remember(reactions, columns) { reactions.chunked(columns) } + var expanded by rememberSaveable(reactions.size, columns) { mutableStateOf(false) } + val visibleRows = remember(rows, expanded) { + if (expanded || rows.size <= 3) rows else rows.take(3) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + visibleRows.forEach { rowItems -> + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth( + if (rowItems.size == columns) 1f else rowItems.size / columns.toFloat() + ) + .align(Alignment.Center), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + rowItems.forEach { item -> + StoryReactionPickerChip( + modifier = Modifier.weight(1f), + item = item, + selected = item.reaction == selectedReaction, + enabled = canSelectReactions && + (!item.needsPremium || isPremiumUser) && + !item.reaction.isPaid, + onClick = { onReactionSelected(item.reaction) } + ) + } + } + } + } + if (rows.size > 3) { + StoryReactionPickerExpandButton( + expanded = expanded, + hiddenCount = reactions.size - visibleRows.flatten().size, + onClick = { expanded = !expanded } + ) + } + } + } +} + +@Composable +private fun StoryReactionPickerCustomEmojiButton( + enabled: Boolean, + isPremiumUser: Boolean, + selectedReaction: StoryReactionModel?, + onClick: () -> Unit +) { + val isSelected = selectedReaction?.isCustomEmoji == true + Surface( + onClick = onClick, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + color = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else if (enabled) { + MaterialTheme.colorScheme.surfaceContainerLow + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f) + } + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface( + shape = CircleShape, + color = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + ) { + Box( + modifier = Modifier.size(36.dp), + contentAlignment = Alignment.Center + ) { + if (selectedReaction != null && selectedReaction.isCustomEmoji) { + StoryReactionVisual( + reaction = selectedReaction, + modifier = Modifier.size(20.dp) + ) + } else { + Icon( + imageVector = Icons.Rounded.EmojiEmotions, + contentDescription = null + ) + } + } + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.story_reaction_picker_custom_emoji), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + ) + if (isSelected) { + Text( + text = storyReactionLabel(selectedReaction), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.72f) + ) + } else if (!isPremiumUser) { + Text( + text = stringResource(R.string.story_reaction_picker_custom_emoji_locked), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun StoryReactionPickerExpandButton( + expanded: Boolean, + hiddenCount: Int, + onClick: () -> Unit +) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (expanded) { + stringResource(R.string.statistics_show_less) + } else { + "${stringResource(R.string.action_show_more)}${if (hiddenCount > 0) " ($hiddenCount)" else ""}" + }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +private fun StoryReactionPickerChip( + modifier: Modifier = Modifier, + item: StoryAvailableReactionModel, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit +) { + Surface( + modifier = Modifier + .then(modifier) + .clip(RoundedCornerShape(18.dp)) + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier), + shape = RoundedCornerShape(18.dp), + color = if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else if (enabled) { + MaterialTheme.colorScheme.surfaceContainerHigh + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + ) { + Box( + modifier = Modifier + .padding(horizontal = 10.dp, vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + StoryReactionVisual( + reaction = item.reaction, + modifier = Modifier.size(24.dp) + ) + if (item.needsPremium) { + Text( + text = stringResource(R.string.story_reaction_picker_premium_badge), + style = MaterialTheme.typography.labelSmall, + color = if (selected) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.82f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.82f) + }, + textAlign = TextAlign.Center + ) + } + } + } + } +} + +@Composable +private fun StoryInteractionSupportingContent( + reaction: StoryReactionModel?, + label: String, + postedAt: String +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = label, + modifier = Modifier.weight(1f, fill = false), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (reaction?.customEmojiId != null) { + StoryReactionVisual( + reaction = reaction, + modifier = Modifier.size(18.dp) + ) + } + Text( + text = "• $postedAt", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +internal fun StoryReactionVisual( + reaction: StoryReactionModel, + modifier: Modifier = Modifier +) { + val customEmojiId = reaction.customEmojiId + if (customEmojiId != null) { + val stickerRepository: StickerRepository = koinInject() + val path by stickerRepository.getCustomEmojiFile(customEmojiId) + .collectAsState(initial = null) + if (path != null) { + StickerImage( + path = path, + modifier = modifier + ) + } else { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Text( + text = storyReactionLabel(reaction), + style = MaterialTheme.typography.titleMedium + ) + } + } + } else { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Text( + text = storyReactionLabel(reaction), + style = MaterialTheme.typography.titleMedium + ) + } + } +} + +@Composable +private fun storyReactionUnavailabilityLabel( + reason: StoryReactionUnavailabilityReasonModel +): String { + return when (reason) { + StoryReactionUnavailabilityReasonModel.ANONYMOUS_ADMINISTRATOR -> { + stringResource(R.string.story_reaction_picker_unavailable_anonymous_admin) + } + + StoryReactionUnavailabilityReasonModel.GUEST -> { + stringResource(R.string.story_reaction_picker_unavailable_guest) + } + + StoryReactionUnavailabilityReasonModel.RESTRICTED -> { + stringResource(R.string.story_reaction_picker_unavailable_restricted) + } + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt index e6ab11e9f..6105820e7 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt @@ -114,10 +114,15 @@ internal fun StoryViewerScaffoldComponent( var isVideoPlaying by remember(story?.id) { mutableStateOf(false) } var isLinksSheetVisible by remember(story?.id) { mutableStateOf(false) } var playerView by remember(story?.id) { mutableStateOf(null) } - var selectedSuggestedReaction by remember(story?.id) { - mutableStateOf( - null - ) + var selectedReactionOverride by remember(story?.id) { + mutableStateOf(null) + } + val selectedReaction = selectedReactionOverride ?: story?.chosenReaction + + LaunchedEffect(story?.id, story?.chosenReaction, selectedReactionOverride) { + if (story?.chosenReaction == selectedReactionOverride) { + selectedReactionOverride = null + } } val isMediaScaledToFill = state.isStoryMediaStretchEnabled val advanceStory by rememberUpdatedState(newValue = { @@ -179,56 +184,37 @@ internal fun StoryViewerScaffoldComponent( onVideoBufferingChange = { isVideoBuffering = it }, onVideoPlayingChange = { isVideoPlaying = it }, onVideoCompleted = advanceStory, + onPreviousTap = { + if (shouldRestartCurrentStoryFromPreviousTap(currentProgress) || !state.canGoPrevious) { + restartPlaybackToken += 1 + isVideoPaused = false + } else { + component.previousStory() + } + }, + onNextTap = { + if (state.canGoNext) { + component.nextStory() + } else { + component.dismiss() + } + }, onStoryAreaClick = { areaType -> when (areaType) { is StoryAreaTypeModel.Link -> component.openStoryLink(areaType.url) is StoryAreaTypeModel.SuggestedReaction -> { - selectedSuggestedReaction = areaType.reaction + selectedReactionOverride = areaType.reaction component.setStoryReaction(areaType.reaction) } else -> Unit } }, - selectedReaction = selectedSuggestedReaction, + selectedReaction = selectedReaction, onPlayerViewChanged = { playerView = it } ) } - Row(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .clickable( - enabled = story != null, - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = { - if (shouldRestartCurrentStoryFromPreviousTap(currentProgress) || !state.canGoPrevious) { - restartPlaybackToken += 1 - isVideoPaused = false - } else { - component.previousStory() - } - } - ) - ) - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .clickable( - enabled = story != null, - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = { - if (state.canGoNext) component.nextStory() else component.dismiss() - } - ) - ) - } - StoryViewerChrome( state = state, story = story, @@ -246,14 +232,7 @@ internal fun StoryViewerScaffoldComponent( if (story?.canGetInteractions == true) { component.showStoryInteractions() } else { - val suggestedReaction = story?.areas - ?.firstNotNullOfOrNull { area -> - area.type as? StoryAreaTypeModel.SuggestedReaction - } - suggestedReaction?.let { - selectedSuggestedReaction = it.reaction - component.setStoryReaction(it.reaction) - } + component.showStoryReactionPicker() } }, onMediaScaleToggle = component::setStoryMediaStretchEnabled, @@ -331,6 +310,20 @@ internal fun StoryViewerScaffoldComponent( onInteractionClick = component::openProfile ) } + + if (state.isStoryReactionPickerVisible) { + StoryReactionPickerSheet( + availableReactions = state.storyAvailableReactions, + selectedReaction = selectedReaction, + isLoading = state.isStoryReactionPickerLoading, + isPremiumUser = state.isPremiumUser, + onDismiss = component::dismissStoryReactionPicker, + onReactionSelected = { reaction -> + selectedReactionOverride = reaction + component.setStoryReaction(reaction) + } + ) + } } } @@ -365,6 +358,8 @@ private fun StoryMediaScene( onVideoBufferingChange: (Boolean) -> Unit, onVideoPlayingChange: (Boolean) -> Unit, onVideoCompleted: () -> Unit, + onPreviousTap: () -> Unit, + onNextTap: () -> Unit, onStoryAreaClick: (StoryAreaTypeModel) -> Unit, selectedReaction: org.monogram.domain.models.stories.StoryReactionModel?, onPlayerViewChanged: (PlayerView?) -> Unit @@ -486,6 +481,12 @@ private fun StoryMediaScene( } + StoryViewerTapZones( + enabled = story != null, + onPreviousTap = onPreviousTap, + onNextTap = onNextTap + ) + Box( modifier = Modifier .fillMaxSize() @@ -510,6 +511,38 @@ private fun StoryMediaScene( } } +@Composable +private fun StoryViewerTapZones( + enabled: Boolean, + onPreviousTap: () -> Unit, + onNextTap: () -> Unit +) { + Row(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable( + enabled = enabled, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onPreviousTap + ) + ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable( + enabled = enabled, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onNextTap + ) + ) + } +} + @Composable private fun StoryAreaOverlays( story: StoryModel, @@ -666,11 +699,9 @@ private fun StoryAreaChip( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = storyReactionLabel(areaType.reaction), - style = MaterialTheme.typography.titleMedium, - color = contentColor, - maxLines = 1 + StoryReactionVisual( + reaction = areaType.reaction, + modifier = Modifier.size(24.dp) ) Text( text = formatCompactStoryCount(areaType.totalCount), @@ -732,11 +763,9 @@ private fun StoryAreaLeading( ) { when (areaType) { is StoryAreaTypeModel.SuggestedReaction -> { - Text( - text = storyReactionLabel(areaType.reaction), - style = MaterialTheme.typography.titleMedium, - color = contentColor, - maxLines = 1 + StoryReactionVisual( + reaction = areaType.reaction, + modifier = Modifier.size(22.dp) ) } diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 9e9f5b8fa..d7beb018b 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -2417,6 +2417,18 @@ Выключить звук истории Включить звук истории Реакции к истории + Реакция на историю + Назад к реакциям + Доступных реакций нет + Популярные реакции + Недавние + Популярные + Открыть эмодзи и кастомные эмодзи + Кастомные эмодзи доступны только с Premium + Premium + Реакции недоступны при публикации от имени анонимного администратора. + Вступите в чат, чтобы реагировать на эту историю. + С этого аккаунта нельзя реагировать на эту историю. Медиафайл всё ещё загружается Растянуть Местоположение @@ -2465,6 +2477,7 @@ Реакций: %1$d Просмотрена Просмотрена с %1$s + Просмотрена с реакцией Переслана как сообщение Опубликована как история Доступно слотов для историй: %1$d diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 0695d1c7e..11c573bca 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -591,6 +591,18 @@ Mute story audio Unmute story audio Story reactions + React to story + Back to reactions + No reactions available + Top reactions + Recent + Popular + Browse emojis and custom emoji + Custom emoji requires Premium + Premium + Reactions are unavailable while posting as an anonymous administrator. + Join the chat to react to this story. + You can’t react to this story from the current account. Media is still loading Stretch Location @@ -645,6 +657,7 @@ %1$d reactions Viewed Viewed with %1$s + Viewed with reaction Forwarded as message Reposted as story %1$d story slots available From 37e23bddbba12c94530a113313bced289bf597a0 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:33:27 +0300 Subject: [PATCH 12/16] stories: Startup load --- .../java/org/monogram/data/di/dataModule.kt | 3 +- .../org/monogram/data/infra/OfflineWarmup.kt | 42 ++++++++++++++++++- .../chats/list/DefaultChatListComponent.kt | 39 +++++++++++++---- 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index ca6ec4c59..863f5c6fc 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -192,7 +192,8 @@ val dataModule = module { chatFullInfoDao = get(), messageMapper = get(), chatCache = get(), - stickerRepository = get() + stickerRepository = get(), + storyRepository = get() ) } single(createdAtStart = true) { diff --git a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt index 9891e905e..7a10b55dc 100644 --- a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt +++ b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt @@ -20,7 +20,9 @@ import org.monogram.data.gateway.TelegramGateway import org.monogram.data.mapper.MessageMapper import org.monogram.data.mapper.user.toEntity import org.monogram.data.mapper.user.toTdApi +import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.repository.StickerRepository +import org.monogram.domain.repository.StoryRepository private const val TAG = "OfflineWarmup" @@ -35,7 +37,8 @@ class OfflineWarmup( private val chatFullInfoDao: ChatFullInfoDao, private val messageMapper: MessageMapper, private val chatCache: ChatCache, - private val stickerRepository: StickerRepository + private val stickerRepository: StickerRepository, + private val storyRepository: StoryRepository ) { @Volatile private var warmupStarted = false @@ -57,6 +60,7 @@ class OfflineWarmup( if (topChats.isEmpty()) return warmupStickers() + warmupStories(topChats) warmupUsers(topChats) warmupChatFullInfo(topChats) warmupMessages(topChats) @@ -67,6 +71,38 @@ class OfflineWarmup( coRunCatching { stickerRepository.loadCustomEmojiStickerSets() } } + private suspend fun warmupStories(chats: List) { + coRunCatching { storyRepository.loadActiveStories(StoryListType.MAIN) } + coRunCatching { storyRepository.loadActiveStories(StoryListType.ARCHIVE) } + + val hintedChats = chats.asSequence() + .filter { it.activeStoryId != 0 || !it.activeStoryStateType.isNullOrBlank() } + .take(STORY_CHAT_WARMUP_LIMIT) + .toList() + if (hintedChats.isEmpty()) return + + for (chat in hintedChats) { + val activeStories = coRunCatching { + storyRepository.getChatActiveStories(chat.id) + }.getOrNull() + + val candidateStoryIds = LinkedHashSet().apply { + chat.activeStoryId.takeIf { it > 0 }?.let(::add) + activeStories?.stories + .orEmpty() + .take(STORY_PER_CHAT_WARMUP_LIMIT) + .forEach { add(it.storyId) } + } + + for (storyId in candidateStoryIds) { + coRunCatching { storyRepository.getStory(chat.id, storyId) } + delay(STORY_WARMUP_DELAY_MS) + } + + delay(STORY_CHAT_WARMUP_DELAY_MS) + } + } + private suspend fun warmupUsers(chats: List) { val userIds = LinkedHashSet() val privateUserIds = LinkedHashSet() @@ -349,6 +385,10 @@ class OfflineWarmup( private companion object { private const val USER_WARMUP_LIMIT = 15 private const val USER_WARMUP_DELAY_MS = 150L + private const val STORY_CHAT_WARMUP_LIMIT = 12 + private const val STORY_PER_CHAT_WARMUP_LIMIT = 2 + private const val STORY_WARMUP_DELAY_MS = 120L + private const val STORY_CHAT_WARMUP_DELAY_MS = 180L private const val ONE_DAY_MS = 24L * 60 * 60 * 1000 private const val SEVEN_DAYS_MS = 7L * ONE_DAY_MS } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt index f7aebde2d..3f70a852a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt @@ -26,6 +26,7 @@ import org.monogram.domain.models.ChatModel import org.monogram.domain.models.ChatType import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.UpdateState +import org.monogram.domain.models.stories.ActiveStoryListModel import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.repository.AttachMenuBotRepository import org.monogram.domain.repository.AuthRepository @@ -1281,7 +1282,7 @@ class DefaultChatListComponent( .filter { chat -> storyRepository.activeStories.value.values .asSequence() - .flatMap(List::asSequence) + .flatMap(List::asSequence) .none { active -> active.chatId == chat.id } } .filter { chat -> storyPrefetchChatIds.add(chat.id) } @@ -1313,7 +1314,8 @@ class DefaultChatListComponent( scope.launch(Dispatchers.IO) { val repositoryStories = storyRepository.activeStories.value val storyListChatCounts = storyRepository.storyListChatCounts.value - val activeStories = repositoryStories[StoryListType.MAIN].orEmpty() + val combinedStories = repositoryStories[StoryListType.MAIN].orEmpty() + + repositoryStories[StoryListType.ARCHIVE].orEmpty() val archiveFolderChatIds = _state.value.chatsByFolder[ARCHIVE_FOLDER_ID] .orEmpty() .mapTo(mutableSetOf()) { it.id } @@ -1322,7 +1324,7 @@ class DefaultChatListComponent( chats.forEach { chat -> localChatIndex.putIfAbsent(chat.id, chat) } } - val resolvedStories = activeStories.mapNotNull { storyList -> + val resolvedStories = combinedStories.mapNotNull { storyList -> val chat = localChatIndex[storyList.chatId] ?: chatListRepository.getChatById( storyList.chatId ) @@ -1342,12 +1344,18 @@ class DefaultChatListComponent( } } - val mainStories = resolvedStories - .filterNot { (_, _, isArchived) -> isArchived } - .map { (_, stories, _) -> stories } - val archiveStories = resolvedStories + val visibleStories = resolvedStories.groupBy( + keySelector = { (_, stories, _) -> stories.listType }, + valueTransform = { (_, stories, _) -> stories } + ) + val archivedChatIds = resolvedStories + .asSequence() .filter { (_, _, isArchived) -> isArchived } - .map { (_, stories, _) -> stories } + .mapTo(mutableSetOf()) { (_, stories, _) -> stories.chatId } + val (mainStories, archiveStories) = resolveDisplayedStoryLists( + repositoryStories = visibleStories, + archivedChatIds = archivedChatIds + ) val isMainStoriesLoaded = storyListChatCounts.containsKey(StoryListType.MAIN) || repositoryStories.containsKey(StoryListType.MAIN) val isArchiveStoriesLoaded = storyListChatCounts.containsKey(StoryListType.ARCHIVE) || @@ -1355,7 +1363,7 @@ class DefaultChatListComponent( Log.d( STORY_TAG, - "refreshStoriesState reason=$reason total=${activeStories.size} main=${mainStories.size} archived=${archiveStories.size} archiveFolderIds=${archiveFolderChatIds.size} mainLoaded=$isMainStoriesLoaded archiveLoaded=$isArchiveStoriesLoaded" + "refreshStoriesState reason=$reason total=${combinedStories.size} main=${mainStories.size} archived=${archiveStories.size} archiveFolderIds=${archiveFolderChatIds.size} mainLoaded=$isMainStoriesLoaded archiveLoaded=$isArchiveStoriesLoaded" ) _storiesState.value = ChatListComponent.StoriesState( @@ -1527,6 +1535,19 @@ class DefaultChatListComponent( } } +internal fun resolveDisplayedStoryLists( + repositoryStories: Map>, + archivedChatIds: Set +): Pair, List> { + val explicitArchiveStories = repositoryStories[StoryListType.ARCHIVE].orEmpty() + val explicitArchiveIds = explicitArchiveStories.mapTo(mutableSetOf()) { it.chatId } + val mainStories = repositoryStories[StoryListType.MAIN].orEmpty() + .filterNot { it.chatId in archivedChatIds || it.chatId in explicitArchiveIds } + val archiveFallbackStories = repositoryStories[StoryListType.MAIN].orEmpty() + .filter { it.chatId in archivedChatIds && it.chatId !in explicitArchiveIds } + return mainStories to (explicitArchiveStories + archiveFallbackStories) +} + private fun hasUnreadState(chat: ChatModel): Boolean { return chat.unreadCount > 0 || chat.isMarkedAsUnread || From 25320206e588562c21fad7b8640461ee42c1623e Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:09:00 +0300 Subject: [PATCH 13/16] stories: Profile stories and archive --- .../org/monogram/data/mapper/StoryMapper.kt | 24 +- .../data/repository/StoryRepositoryImpl.kt | 75 ++++++ .../domain/models/stories/StoryModels.kt | 14 +- .../domain/repository/StoryRepository.kt | 18 ++ .../profile/DefaultProfileComponent.kt | 114 ++++++++- .../features/profile/ProfileComponent.kt | 23 +- .../features/profile/ProfileContent.kt | 95 +------ .../features/profile/ProfileTabs.kt | 9 +- .../profile/components/ProfileMediaSection.kt | 234 +++++++++++++++++- .../stories/DefaultStoriesHostComponent.kt | 211 +++++++++++++++- .../features/stories/StoriesHostComponent.kt | 11 + .../features/stories/StoriesHostContent.kt | 50 +++- .../components/StoryViewerComponents.kt | 96 +++---- .../components/StoryViewerSceneComponents.kt | 40 +-- .../presentation/root/DefaultRootComponent.kt | 8 +- .../src/main/res/values-ru-rRU/string.xml | 7 + presentation/src/main/res/values/string.xml | 7 + 17 files changed, 859 insertions(+), 177 deletions(-) diff --git a/data/src/main/java/org/monogram/data/mapper/StoryMapper.kt b/data/src/main/java/org/monogram/data/mapper/StoryMapper.kt index 708362048..83bcea5b0 100644 --- a/data/src/main/java/org/monogram/data/mapper/StoryMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/StoryMapper.kt @@ -11,6 +11,7 @@ import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.models.stories.StoryMediaModel import org.monogram.domain.models.stories.StoryMediaType import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryPageModel import org.monogram.domain.models.stories.StoryPostCapabilityModel import org.monogram.domain.models.stories.StoryPrivacyMode import org.monogram.domain.models.stories.StoryPrivacySettingsModel @@ -73,7 +74,28 @@ object StoryMapper { canGetInteractions = story.canGetInteractions, hasExpiredViewers = story.hasExpiredViewers, isRead = activeStories?.stories?.firstOrNull { it.storyId == story.id }?.isRead - ?: (story.id <= (activeStories?.maxReadStoryId ?: 0)) + ?: (story.id <= (activeStories?.maxReadStoryId ?: 0)), + viewCount = story.interactionInfo?.viewCount ?: 0, + forwardCount = story.interactionInfo?.forwardCount ?: 0, + reactionCount = story.interactionInfo?.reactionCount ?: 0 + ) + } + + fun mapStoryPage( + stories: TdApi.Stories, + activeStories: ActiveStoryListModel? = null, + mediaResolver: (TdApi.Story) -> StoryMediaModel? = { null } + ): StoryPageModel { + return StoryPageModel( + totalCount = stories.totalCount, + pinnedStoryIds = stories.pinnedStoryIds?.toList().orEmpty(), + stories = stories.stories.orEmpty().map { story -> + mapStory( + story = story, + activeStories = activeStories, + mediaOverride = mediaResolver(story) + ) + } ) } diff --git a/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt index e5361b333..1bd5799bf 100644 --- a/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt @@ -27,6 +27,7 @@ import org.monogram.domain.models.stories.StoryMediaModel import org.monogram.domain.models.stories.StoryMediaType import org.monogram.domain.models.stories.StoryModel import org.monogram.domain.models.stories.StoryOptionsModel +import org.monogram.domain.models.stories.StoryPageModel import org.monogram.domain.models.stories.StoryPostCapabilityModel import org.monogram.domain.models.stories.StoryPostResultModel import org.monogram.domain.models.stories.StoryReactionModel @@ -163,6 +164,60 @@ class StoryRepositoryImpl( }.getOrElse { emptyList() } } + override suspend fun getChatPostedToChatPageStories( + chatId: Long, + fromStoryId: Int, + limit: Int + ): StoryPageModel? { + return runCatching { + val active = getChatActiveStories(chatId) + val page = StoryMapper.mapStoryPage( + stories = gateway.execute( + TdApi.GetChatPostedToChatPageStories( + chatId, + fromStoryId, + limit + ) + ), + activeStories = active, + mediaResolver = { story -> mapStoryMediaBestEffort(story.content) } + ) + var current = state.value + page.stories.forEach { story -> + current = StoryRepositoryStateReducer.withStory(current, story) + } + applyState(current) + page + }.getOrNull() + } + + override suspend fun getChatArchivedStories( + chatId: Long, + fromStoryId: Int, + limit: Int + ): StoryPageModel? { + return runCatching { + val active = getChatActiveStories(chatId) + val page = StoryMapper.mapStoryPage( + stories = gateway.execute( + TdApi.GetChatArchivedStories( + chatId, + fromStoryId, + limit + ) + ), + activeStories = active, + mediaResolver = { story -> mapStoryMediaBestEffort(story.content) } + ) + var current = state.value + page.stories.forEach { story -> + current = StoryRepositoryStateReducer.withStory(current, story) + } + applyState(current) + page + }.getOrNull() + } + override suspend fun openStory(chatId: Long, storyId: Int) { runCatching { gateway.execute(TdApi.OpenStory(chatId, storyId)) } } @@ -312,6 +367,26 @@ class StoryRepositoryImpl( }.getOrDefault(false) } + override suspend fun toggleStoryPostedToChatPage( + chatId: Long, + storyId: Int, + isPostedToChatPage: Boolean + ): Boolean { + return runCatching { + gateway.execute( + TdApi.ToggleStoryIsPostedToChatPage( + chatId, + storyId, + isPostedToChatPage + ) + ) + getStory(chatId, storyId)?.let { updatedStory -> + applyState(StoryRepositoryStateReducer.withStory(state.value, updatedStory)) + } + true + }.getOrDefault(false) + } + override suspend fun setChatActiveStoriesList(chatId: Long, listType: StoryListType?): Boolean { return runCatching { gateway.execute(TdApi.SetChatActiveStoriesList(chatId, listType?.toTdStoryList())) diff --git a/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt b/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt index 15b920d91..50bf03711 100644 --- a/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt +++ b/domain/src/main/java/org/monogram/domain/models/stories/StoryModels.kt @@ -44,9 +44,21 @@ data class StoryModel( val canGetStatistics: Boolean = false, val canGetInteractions: Boolean = false, val hasExpiredViewers: Boolean = false, - val isRead: Boolean = false + val isRead: Boolean = false, + val viewCount: Int = 0, + val forwardCount: Int = 0, + val reactionCount: Int = 0 ) +data class StoryPageModel( + val totalCount: Int, + val pinnedStoryIds: List = emptyList(), + val stories: List +) { + val nextFromStoryId: Int + get() = stories.lastOrNull()?.id ?: 0 +} + data class StoryAreaModel( val position: StoryAreaPositionModel, val type: StoryAreaTypeModel diff --git a/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt b/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt index 2ee0a875d..a5e5f552c 100644 --- a/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/StoryRepository.kt @@ -8,6 +8,7 @@ import org.monogram.domain.models.stories.StoryInteractionPageModel import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.models.stories.StoryModel import org.monogram.domain.models.stories.StoryOptionsModel +import org.monogram.domain.models.stories.StoryPageModel import org.monogram.domain.models.stories.StoryPostCapabilityModel import org.monogram.domain.models.stories.StoryPostResultModel import org.monogram.domain.models.stories.StoryReactionModel @@ -32,6 +33,18 @@ interface StoryRepository { limit: Int = 50 ): List + suspend fun getChatPostedToChatPageStories( + chatId: Long, + fromStoryId: Int = 0, + limit: Int = 50 + ): StoryPageModel? + + suspend fun getChatArchivedStories( + chatId: Long, + fromStoryId: Int = 0, + limit: Int = 50 + ): StoryPageModel? + suspend fun openStory(chatId: Long, storyId: Int) suspend fun closeStory(chatId: Long, storyId: Int) suspend fun activateStealthMode(): Boolean @@ -61,6 +74,11 @@ interface StoryRepository { suspend fun postStory(chatId: Long, draft: StoryComposerDraftModel): StoryPostResultModel suspend fun editStory(chatId: Long, storyId: Int, draft: StoryComposerDraftModel): Boolean suspend fun deleteStory(chatId: Long, storyId: Int): Boolean + suspend fun toggleStoryPostedToChatPage( + chatId: Long, + storyId: Int, + isPostedToChatPage: Boolean + ): Boolean suspend fun setChatActiveStoriesList(chatId: Long, listType: StoryListType?): Boolean fun clearLastPostResult() } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt index 21f530de6..7998c57d9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt @@ -5,6 +5,7 @@ import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first @@ -60,6 +61,7 @@ class DefaultProfileComponent( private val onMemberClicked: (Long) -> Unit = {}, private val onMemberLongClicked: (Long, Long) -> Unit = { _, _ -> }, private val onOpenStoriesClicked: (Long, Int?) -> Unit = { _, _ -> }, + private val onOpenPostedStoriesClicked: (Long, Int?) -> Unit = { _, _ -> }, private val onOpenStoryArchiveClicked: (Long) -> Unit = {}, private val onCreateStoryClicked: (Long) -> Unit = {}, private val onShareToStoryRequested: (String, String?, String?) -> Unit = { _, _, _ -> } @@ -92,6 +94,7 @@ class DefaultProfileComponent( private val PROFILE_PHOTOS_LIMIT = 50 private var hasStartedProfilePhotoListPreload = false private val loadingTabs = mutableSetOf() + private var profileStoriesJob: Job? = null init { loadData() @@ -148,7 +151,8 @@ class DefaultProfileComponent( if (visibleTabs.any { it.key == currentSelectedTabKey }) { currentSelectedTabKey } else { - visibleTabs.firstOrNull { it.initiallySelected }?.key ?: ProfileTabKey.MEDIA + visibleTabs.firstOrNull { it.initiallySelected }?.key + ?: ProfileTabKey.STORIES } _state.update { @@ -170,6 +174,8 @@ class DefaultProfileComponent( ) } + refreshProfileStories() + ensureTabLoaded(selectedTabKey) preloadProfilePhotoList( @@ -187,23 +193,97 @@ class DefaultProfileComponent( userRepository.currentUserFlow .onEach { user -> _state.update { it.copy(currentUser = user) } + refreshProfileStories() } .launchIn(scope) storyRepository.activeStories .onEach { activeStories -> + val activeStoryList = activeStories.values + .asSequence() + .flatMap { lists -> lists.asSequence() } + .firstOrNull { list -> list.chatId == chatId } + val activeStoryPreviews = resolveActiveStoryPreviews(activeStoryList) _state.update { it.copy( - activeStoryList = activeStories.values - .asSequence() - .flatMap { lists -> lists.asSequence() } - .firstOrNull { list -> list.chatId == chatId } + activeStoryList = activeStoryList, + activeStories = activeStoryPreviews ) } } .launchIn(scope) } + private fun refreshProfileStories() { + profileStoriesJob?.cancel() + profileStoriesJob = scope.launch { + _state.update { it.copy(isStoriesLoading = true) } + try { + val snapshot = _state.value + val postedStoriesHint = snapshot.fullInfo?.hasPinnedStories == true || + snapshot.fullInfo?.hasPostedToProfileStories == true + + val activeStoryList = coRunCatching { + storyRepository.getChatActiveStories(chatId) + }.getOrNull() + val activeStoryPreviews = + resolveActiveStoryPreviews(activeStoryList ?: snapshot.activeStoryList) + + val shouldLoadPostedStories = postedStoriesHint || isCurrentUserProfile(snapshot) + val postedStoriesPage = if (shouldLoadPostedStories) { + coRunCatching { + storyRepository.getChatPostedToChatPageStories( + chatId = chatId, + limit = PROFILE_POSTED_STORIES_LIMIT + ) + }.getOrNull() + } else { + null + } + + _state.update { current -> + current.copy( + activeStoryList = activeStoryList ?: current.activeStoryList, + activeStories = when { + (activeStoryList + ?: current.activeStoryList)?.stories.isNullOrEmpty() -> emptyList() + + activeStoryPreviews.isNotEmpty() -> activeStoryPreviews + else -> current.activeStories + }, + postedStories = when { + postedStoriesPage != null -> postedStoriesPage.stories + shouldLoadPostedStories -> current.postedStories + else -> emptyList() + }, + postedStoryCount = postedStoriesPage?.totalCount + ?: current.postedStoryCount, + hasPostedStoriesHint = postedStoriesHint || + (postedStoriesPage?.totalCount ?: 0) > 0 + ) + } + } finally { + _state.update { it.copy(isStoriesLoading = false) } + } + } + } + + private suspend fun resolveActiveStoryPreviews( + activeStoryList: org.monogram.domain.models.stories.ActiveStoryListModel? + ): List { + val summaries = activeStoryList?.stories.orEmpty().take(PROFILE_ACTIVE_STORIES_LIMIT) + if (summaries.isEmpty()) return emptyList() + + val existingById = _state.value.activeStories.associateBy { it.id } + return summaries.mapNotNull { summary -> + coRunCatching { + storyRepository.getStory(chatId = chatId, storyId = summary.storyId) + }.getOrNull() + ?.copy(isRead = summary.isRead) + ?: existingById[summary.storyId]?.copy(isRead = summary.isRead) + } + } + override fun onLoadMoreMedia() { loadNextPage(_state.value.selectedTabKey) } @@ -298,6 +378,7 @@ class DefaultProfileComponent( private fun ensureTabLoaded(tabKey: ProfileTabKey) { when (tabKey) { + ProfileTabKey.STORIES -> Unit ProfileTabKey.MEMBERS -> { if (!_state.value.membersTab.hasLoaded) { loadMembersNextPage() @@ -314,6 +395,7 @@ class DefaultProfileComponent( private fun loadNextPage(tabKey: ProfileTabKey) { when (tabKey) { + ProfileTabKey.STORIES -> Unit ProfileTabKey.MEMBERS -> loadMembersNextPage() else -> loadMessageTabNextPage(tabKey) } @@ -940,6 +1022,11 @@ class DefaultProfileComponent( return snapshot.chatId < 0 } + private fun isCurrentUserProfile(snapshot: ProfileComponent.State = _state.value): Boolean { + val userId = snapshot.user?.id ?: return false + return snapshot.currentUser?.id == userId + } + override fun onToggleContact() { val user = _state.value.user ?: return onEditContactClicked(user.id) @@ -1188,6 +1275,18 @@ class DefaultProfileComponent( onOpenStoriesClicked(chatId, firstStoryId) } + override fun onOpenActiveStory(storyId: Int) { + onOpenStoriesClicked(chatId, storyId) + } + + override fun onOpenPostedStories() { + onOpenPostedStoriesClicked(chatId, _state.value.postedStories.firstOrNull()?.id) + } + + override fun onOpenPostedStory(storyId: Int) { + onOpenPostedStoriesClicked(chatId, storyId) + } + override fun onOpenStoryArchive() { onOpenStoryArchiveClicked(chatId) } @@ -1339,4 +1438,9 @@ class DefaultProfileComponent( _state.update { it.copy(actionState = ChatActionState.Idle) } } } + + companion object { + private const val PROFILE_ACTIVE_STORIES_LIMIT = 20 + private const val PROFILE_POSTED_STORIES_LIMIT = 50 + } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt index f498fb878..4c374b829 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt @@ -10,6 +10,7 @@ import org.monogram.domain.models.GroupMemberModel import org.monogram.domain.models.MessageModel import org.monogram.domain.models.UserModel import org.monogram.domain.models.stories.ActiveStoryListModel +import org.monogram.domain.models.stories.StoryModel import org.monogram.domain.repository.ChatMemberStatus import org.monogram.domain.repository.MessageRepository import org.monogram.presentation.core.util.IDownloadUtils @@ -86,6 +87,9 @@ interface ProfileComponent { fun onLocationClick(lat: Double, lon: Double, address: String) fun onDismissLocation() fun onOpenStories() + fun onOpenActiveStory(storyId: Int) + fun onOpenPostedStories() + fun onOpenPostedStory(storyId: Int) fun onOpenStoryArchive() fun onCreateStory() @@ -101,7 +105,7 @@ interface ProfileComponent { val publicLink: String? = null, val visibleTabs: List = emptyList(), - val selectedTabKey: ProfileTabKey = ProfileTabKey.MEDIA, + val selectedTabKey: ProfileTabKey = ProfileTabKey.STORIES, val messageTabs: Map = defaultMessageTabStates(), val membersTab: MembersTabState = MembersTabState(), @@ -164,8 +168,22 @@ interface ProfileComponent { val pendingMiniAppName: String? = null, val selectedLocation: LocationData? = null, - val activeStoryList: ActiveStoryListModel? = null + val isStoriesLoading: Boolean = false, + val activeStoryList: ActiveStoryListModel? = null, + val activeStories: List = emptyList(), + val postedStories: List = emptyList(), + val postedStoryCount: Int = 0, + val hasPostedStoriesHint: Boolean = false ) { + val activeStoryCount: Int + get() = activeStoryList?.stories?.size ?: 0 + + val hasPostedStories: Boolean + get() = hasPostedStoriesHint || postedStoryCount > 0 + + val hasAnyStories: Boolean + get() = activeStories.isNotEmpty() || postedStories.isNotEmpty() || hasPostedStories + val selectedTab: ProfileTabSpec? get() = visibleTabs.firstOrNull { it.key == selectedTabKey } @@ -221,6 +239,7 @@ interface ProfileComponent { private fun defaultMessageTabStates(): Map = listOf( + ProfileTabKey.STORIES, ProfileTabKey.MEDIA, ProfileTabKey.FILES, ProfileTabKey.MUSIC, diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt index 3165c3581..73a58174d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -24,19 +23,12 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ExitToApp -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.Archive import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -106,7 +98,6 @@ fun ProfileContent(component: ProfileComponent) { val chat = state.chat val user = state.user val isCurrentUserProfile = user != null && state.currentUser?.id == user.id - val canManageStories = isCurrentUserProfile || chat?.isAdmin == true val isInitialLoading = state.isLoading && chat == null && user == null val avatarPath = remember(state.chat, state.user, state.personalAvatarPath) { @@ -375,16 +366,6 @@ fun ProfileContent(component: ProfileComponent) { showLinkedChat = state.chatId < 0 ) } else { - if (canManageStories || state.activeStoryList != null) { - ProfileStoriesCard( - canManageStories = canManageStories, - storyCount = state.activeStoryList?.stories?.size ?: 0, - onOpenStories = component::onOpenStories, - onOpenArchive = component::onOpenStoryArchive, - onCreateStory = component::onCreateStory - ) - Spacer(modifier = Modifier.height(12.dp)) - } ProfileInfoSection( state = state, localClipboard = localClipboard, @@ -421,7 +402,10 @@ fun ProfileContent(component: ProfileComponent) { onMemberLongClick = component::onMemberLongClick, onLoadMedia = { msg -> component.onDownloadMedia(msg) - } + }, + onOpenActiveStory = component::onOpenActiveStory, + onOpenPostedStory = component::onOpenPostedStory, + onOpenArchive = component::onOpenStoryArchive ) item(span = { GridItemSpan(3) }) { @@ -525,77 +509,6 @@ fun ProfileContent(component: ProfileComponent) { } } -@Composable -private fun ProfileStoriesCard( - canManageStories: Boolean, - storyCount: Int, - onOpenStories: () -> Unit, - onOpenArchive: () -> Unit, - onCreateStory: () -> Unit -) { - Surface( - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = stringResource(R.string.profile_feature_stories_title), - style = MaterialTheme.typography.titleMedium - ) - Text( - text = if (storyCount > 0) { - stringResource(R.string.story_profile_count, storyCount) - } else { - stringResource(R.string.profile_feature_stories_subtitle) - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (storyCount > 0) { - FilterChip( - selected = false, - onClick = onOpenStories, - label = { Text(text = stringResource(R.string.story_open)) }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.PlayArrow, - contentDescription = null - ) - } - ) - } - if (canManageStories) { - FilterChip( - selected = false, - onClick = onCreateStory, - label = { Text(text = stringResource(R.string.story_create)) }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.Add, - contentDescription = null - ) - } - ) - FilterChip( - selected = false, - onClick = onOpenArchive, - label = { Text(text = stringResource(R.string.story_archive)) }, - leadingIcon = { - Icon(Icons.Rounded.Archive, contentDescription = null) - } - ) - } - } - } - } -} - @Composable private fun ProfileHeaderSkeleton( progress: Float, diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileTabs.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileTabs.kt index 3b4c7f06e..bc1f0509b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileTabs.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileTabs.kt @@ -6,6 +6,7 @@ import org.monogram.domain.repository.ProfileMediaFilter import org.monogram.presentation.R enum class ProfileTabKey { + STORIES, MEDIA, MEMBERS, FILES, @@ -16,6 +17,7 @@ enum class ProfileTabKey { } enum class ProfileTabContentType { + STORIES_LIST, MEDIA_GRID, MESSAGE_LIST, MEMBERS_LIST @@ -36,6 +38,7 @@ fun buildProfileTabSpecs( ): List { val supportedKeys = if (isGroupOrChannel) { listOf( + ProfileTabKey.STORIES, ProfileTabKey.MEDIA, ProfileTabKey.MEMBERS, ProfileTabKey.FILES, @@ -46,6 +49,7 @@ fun buildProfileTabSpecs( ) } else { listOf( + ProfileTabKey.STORIES, ProfileTabKey.MEDIA, ProfileTabKey.FILES, ProfileTabKey.MUSIC, @@ -55,7 +59,7 @@ fun buildProfileTabSpecs( ) } - val initialKey = preferredTabKey?.takeIf { it in supportedKeys } ?: ProfileTabKey.MEDIA + val initialKey = preferredTabKey?.takeIf { it in supportedKeys } ?: ProfileTabKey.STORIES return supportedKeys.map { key -> ProfileTabSpec( @@ -80,6 +84,7 @@ fun ProfileTabType?.toProfileTabKeyOrNull(): ProfileTabKey? = fun ProfileTabKey.toProfileMediaFilter(): ProfileMediaFilter? = when (this) { + ProfileTabKey.STORIES -> null ProfileTabKey.MEDIA -> ProfileMediaFilter.MEDIA ProfileTabKey.FILES -> ProfileMediaFilter.FILES ProfileTabKey.MUSIC -> ProfileMediaFilter.AUDIO @@ -91,6 +96,7 @@ fun ProfileTabKey.toProfileMediaFilter(): ProfileMediaFilter? = fun ProfileTabKey.contentType(): ProfileTabContentType = when (this) { + ProfileTabKey.STORIES -> ProfileTabContentType.STORIES_LIST ProfileTabKey.MEDIA, ProfileTabKey.GIFS -> ProfileTabContentType.MEDIA_GRID @@ -104,6 +110,7 @@ fun ProfileTabKey.contentType(): ProfileTabContentType = @StringRes fun ProfileTabKey.titleRes(): Int = when (this) { + ProfileTabKey.STORIES -> R.string.tab_stories ProfileTabKey.MEDIA -> R.string.tab_media ProfileTabKey.MEMBERS -> R.string.tab_members ProfileTabKey.FILES -> R.string.tab_files diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt index 4844ea697..130f3d513 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.rounded.Archive import androidx.compose.material.icons.rounded.BrokenImage import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.Link @@ -35,8 +36,10 @@ import androidx.compose.material.icons.rounded.Mic import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.PermMedia import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.RemoveRedEye import androidx.compose.material.icons.rounded.Verified import androidx.compose.material.icons.rounded.Videocam +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -71,13 +74,17 @@ import org.monogram.domain.models.GroupMemberModel import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.UserStatusType +import org.monogram.domain.models.stories.StoryModel import org.monogram.presentation.R import org.monogram.presentation.core.media.VideoStickerPlayer import org.monogram.presentation.core.media.VideoType import org.monogram.presentation.core.ui.Avatar +import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.core.ui.SettingsTile import org.monogram.presentation.core.ui.rememberShimmerBrush import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.getUserStatusText +import org.monogram.presentation.features.chats.conversation.ui.channel.formatViews import org.monogram.presentation.features.profile.ProfileComponent import org.monogram.presentation.features.profile.ProfileTabContentType import org.monogram.presentation.features.profile.ProfileTabKey @@ -98,7 +105,10 @@ fun LazyGridScope.profileMediaSection( onLoadMore: () -> Unit, onMemberClick: (Long) -> Unit = {}, onMemberLongClick: (Long) -> Unit = {}, - onLoadMedia: (MessageModel) -> Unit = {} + onLoadMedia: (MessageModel) -> Unit = {}, + onOpenActiveStory: (Int) -> Unit = {}, + onOpenPostedStory: (Int) -> Unit = {}, + onOpenArchive: () -> Unit = {} ) { if (tabs.isEmpty()) return @@ -175,6 +185,13 @@ fun LazyGridScope.profileMediaSection( } when (selectedTab.key) { + ProfileTabKey.STORIES -> storiesSection( + state = state, + onOpenActiveStory = onOpenActiveStory, + onOpenPostedStory = onOpenPostedStory, + onOpenArchive = onOpenArchive + ) + ProfileTabKey.MEDIA -> mediaGrid( messages = state.mediaMessages, isLoading = selectedMessageTab.isLoadingInitial, @@ -235,6 +252,7 @@ fun LazyGridScope.profileMediaSection( } val itemCount = when (selectedTab.key) { + ProfileTabKey.STORIES -> 0 ProfileTabKey.MEDIA -> state.mediaMessages.size ProfileTabKey.MEMBERS -> state.members.size ProfileTabKey.FILES -> state.fileMessages.size @@ -244,6 +262,7 @@ fun LazyGridScope.profileMediaSection( ProfileTabKey.GIFS -> state.gifMessages.size } val shouldAutoLoadMore = when (selectedTab.key) { + ProfileTabKey.STORIES -> false ProfileTabKey.MEMBERS -> { selectedMembersTab.canLoadMore && !selectedMembersTab.isLoadingInitial && @@ -274,6 +293,7 @@ fun LazyGridScope.profileMediaSection( selectedMembersTab.items.isNotEmpty() val showMediaPaginationSkeleton = selectedTab.key != ProfileTabKey.MEMBERS && + selectedTab.key != ProfileTabKey.STORIES && selectedMessageTab.isLoadingNext && selectedMessageTab.items.isNotEmpty() @@ -342,6 +362,218 @@ private fun ScrollableRow( } } +private fun LazyGridScope.storiesSection( + state: ProfileComponent.State, + onOpenActiveStory: (Int) -> Unit, + onOpenPostedStory: (Int) -> Unit, + onOpenArchive: () -> Unit +) { + val activeStories = state.activeStories.distinctBy { it.id } + val postedStories = state.postedStories.distinctBy { it.id } + val isCurrentUserProfile = state.user?.id != null && state.currentUser?.id == state.user?.id + val canManageStories = isCurrentUserProfile || state.chat?.isAdmin == true + + if (state.isStoriesLoading && activeStories.isEmpty() && postedStories.isEmpty()) { + item(span = { GridItemSpan(3) }, key = "stories_loading") { + StoriesLoadingState() + } + } else if (activeStories.isEmpty() && postedStories.isEmpty()) { + item(span = { GridItemSpan(3) }, key = "stories_empty") { + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + EmptyState(stringResource(R.string.empty_stories)) + } + } + } else { + if (activeStories.isNotEmpty()) { + item(span = { GridItemSpan(3) }, key = "stories_active_header") { + StorySectionHeader(title = stringResource(R.string.profile_stories_active_section)) + } + item(span = { GridItemSpan(3) }, key = "stories_active_row") { + StoryPreviewRow( + stories = activeStories, + onStoryClick = { story -> onOpenActiveStory(story.id) } + ) + } + } + + if (postedStories.isNotEmpty()) { + item(span = { GridItemSpan(3) }, key = "stories_posted_row") { + StoryPreviewRow( + stories = postedStories, + onStoryClick = { story -> onOpenPostedStory(story.id) } + ) + } + } + } + + if (canManageStories) { + item(span = { GridItemSpan(3) }, key = "stories_archive_spacer") { + Spacer(modifier = Modifier.height(14.dp)) + } + item(span = { GridItemSpan(3) }, key = "stories_archive") { + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + SettingsTile( + icon = Icons.Rounded.Archive, + title = stringResource(R.string.story_archive), + subtitle = stringResource(R.string.profile_stories_archive_subtitle), + iconColor = MaterialTheme.colorScheme.secondary, + position = ItemPosition.STANDALONE, + onClick = onOpenArchive + ) + } + } + } +} + +@Composable +private fun StoriesLoadingState() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + strokeWidth = 2.5.dp + ) + } +} + +@Composable +private fun StorySectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleMediumEmphasized, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 28.dp, end = 20.dp, top = 16.dp, bottom = 8.dp) + ) +} + +@Composable +private fun StoryPreviewRow( + stories: List, + onStoryClick: (StoryModel) -> Unit +) { + ScrollableRow( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 2.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + stories.forEach { story -> + StoryPreviewCard( + story = story, + onClick = { onStoryClick(story) } + ) + } + } +} + +@Composable +private fun StoryPreviewCard( + story: StoryModel, + onClick: () -> Unit +) { + val context = LocalContext.current + val previewModel = + remember(story.media.previewPath, story.media.path, story.media.minithumbnail) { + story.media.previewPath ?: story.media.path ?: story.media.minithumbnail + } + val viewsText = remember(context, story.viewCount) { formatViews(context, story.viewCount) } + + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier + .width(112.dp) + .height(164.dp) + .clickable(onClick = onClick) + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (previewModel != null) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(previewModel) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.BrokenImage, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (story.media.type == org.monogram.domain.models.stories.StoryMediaType.VIDEO) { + Surface( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + shape = CircleShape, + color = Color.Black.copy(alpha = 0.45f) + ) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = stringResource(R.string.media_type_video), + tint = Color.White, + modifier = Modifier.padding(4.dp) + ) + } + } + + if (!story.isRead) { + Box( + modifier = Modifier + .align(Alignment.TopStart) + .padding(10.dp) + .size(8.dp) + .clip(CircleShape) + .background(Color.White) + ) + } + + Surface( + shape = RoundedCornerShape(999.dp), + color = Color.Black.copy(alpha = 0.55f), + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = Icons.Rounded.RemoveRedEye, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(13.dp) + ) + Text( + text = viewsText, + style = MaterialTheme.typography.labelSmall, + color = Color.White + ) + } + } + } + } +} + private fun LazyGridScope.membersList( members: List, isLoading: Boolean, diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt index 2888e7c16..fa0b1e776 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt @@ -102,7 +102,11 @@ class DefaultStoriesHostComponent( } } - override fun openChatStories(chatId: Long, storyId: Int?, listType: StoryListType) { + override fun openChatStories( + chatId: Long, + storyId: Int?, + listType: StoryListType + ) { storyLoadJob?.cancel() storyRefreshJob?.cancel() _state.value = _state.value.copy( @@ -114,6 +118,7 @@ class DefaultStoriesHostComponent( viewerItems = emptyList(), viewerIndex = 0, currentStory = null, + viewerSource = StoryViewerSource.ACTIVE, activeListType = listType, canManageStories = false, composerMode = StoryComposerMode.CREATE, @@ -175,6 +180,7 @@ class DefaultStoriesHostComponent( viewerItems = items, viewerIndex = resolvedIndex, currentStory = story, + viewerSource = StoryViewerSource.ACTIVE, canManageStories = chatPresentation.canManageStories, inlineError = null, showInlineVideo = false, @@ -196,6 +202,7 @@ class DefaultStoriesHostComponent( viewerItems = emptyList(), viewerIndex = 0, currentStory = null, + viewerSource = StoryViewerSource.ALBUM, activeListType = StoryListType.MAIN, canManageStories = false, composerMode = StoryComposerMode.CREATE, @@ -237,6 +244,7 @@ class DefaultStoriesHostComponent( viewerItems = items, viewerIndex = 0, currentStory = stories.first(), + viewerSource = StoryViewerSource.ALBUM, canManageStories = canManageStories(chatId), inlineError = null, showInlineVideo = false, @@ -247,6 +255,119 @@ class DefaultStoriesHostComponent( } } + override fun openProfileStories(chatId: Long, storyId: Int?) { + openProfileStoryPage( + chatId = chatId, + storyId = storyId, + viewerSource = StoryViewerSource.PROFILE, + loadPage = { + storyRepository.getChatPostedToChatPageStories( + chatId = chatId, + limit = PROFILE_STORIES_PAGE_SIZE + ) + }, + emptyMessage = "No profile stories available" + ) + } + + override fun openProfileStoryArchive(chatId: Long, storyId: Int?) { + openProfileStoryPage( + chatId = chatId, + storyId = storyId, + viewerSource = StoryViewerSource.PROFILE_ARCHIVE, + loadPage = { + storyRepository.getChatArchivedStories( + chatId = chatId, + limit = PROFILE_STORIES_PAGE_SIZE + ) + }, + emptyMessage = "No archived profile stories available" + ) + } + + private fun openProfileStoryPage( + chatId: Long, + storyId: Int?, + viewerSource: StoryViewerSource, + loadPage: suspend () -> org.monogram.domain.models.stories.StoryPageModel?, + emptyMessage: String + ) { + storyLoadJob?.cancel() + storyRefreshJob?.cancel() + _state.value = _state.value.copy( + mode = StoriesHostComponent.Mode.Viewer, + isLoading = true, + chatId = chatId, + chatTitle = "", + chatAvatarPath = null, + viewerItems = emptyList(), + viewerIndex = 0, + currentStory = null, + viewerSource = StoryViewerSource.PROFILE, + activeListType = StoryListType.MAIN, + canManageStories = false, + composerMode = StoryComposerMode.CREATE, + editingStoryId = null, + inlineError = null, + isSubmitting = false, + isStoryStatisticsVisible = false, + isStoryStatisticsLoading = false, + storyStatistics = null, + isStoryInteractionsVisible = false, + isStoryInteractionsLoading = false, + storyInteractionsPage = null, + isStoryReactionPickerVisible = false, + isStoryReactionPickerLoading = false, + showInlineVideo = false, + showStoryMediaLoadingMessage = false + ) + syncStoryMediaLoadingMessage() + scope.launch { + val page = loadPage() + val stories = page?.stories.orEmpty() + val items = stories.map { story -> + StoryViewerUiModel( + chatId = story.posterChatId, + storyId = story.id, + date = story.date + ) + } + + if (items.isEmpty()) { + _state.value = _state.value.copy( + mode = StoriesHostComponent.Mode.Hidden, + isLoading = false, + inlineError = null + ) + messageDisplayer.show(emptyMessage) + return@launch + } + + val resolvedIndex = resolveInitialViewerIndex(items, chatId, storyId) + val item = items[resolvedIndex] + val story = loadStory(item) ?: stories.getOrNull(resolvedIndex) + val chatPresentation = resolveChatPresentation(item.chatId) + + _state.value = _state.value.copy( + mode = StoriesHostComponent.Mode.Viewer, + isLoading = story.requiresMediaRefresh(), + chatId = item.chatId, + chatTitle = chatPresentation.title, + chatAvatarPath = chatPresentation.avatarPath, + viewerItems = items, + viewerIndex = resolvedIndex, + currentStory = story, + viewerSource = viewerSource, + canManageStories = chatPresentation.canManageStories, + inlineError = null, + showInlineVideo = false, + showStoryMediaLoadingMessage = false + ) + syncStoryMediaLoadingMessage() + scheduleStoryRefreshIfNeeded(item, story) + } + } + override fun openComposer( chatId: Long, preferredMediaType: StoryMediaType?, @@ -508,6 +629,7 @@ class DefaultStoriesHostComponent( is StorySaveOutcome.Edited -> { Log.d(TAG, "saveStory edit success chatId=$chatId storyId=${result.storyId}") val listType = current.activeListType + val viewerSource = current.viewerSource _state.value = restoreViewerState( _state.value.copy( isSubmitting = false, @@ -515,7 +637,12 @@ class DefaultStoriesHostComponent( editingStoryId = null ) ) - openChatStories(chatId, result.storyId, listType) + reopenStory( + chatId = chatId, + storyId = result.storyId, + viewerSource = viewerSource, + activeListType = listType + ) } is StorySaveOutcome.Failed -> { @@ -551,6 +678,7 @@ class DefaultStoriesHostComponent( } override fun moveCurrentStoryToArchive() { + if (_state.value.viewerSource != StoryViewerSource.ACTIVE) return val chatId = _state.value.chatId ?: return scope.launch { if (storyRepository.setChatActiveStoriesList(chatId, StoryListType.ARCHIVE)) { @@ -563,6 +691,7 @@ class DefaultStoriesHostComponent( } override fun restoreCurrentStoryFromArchive() { + if (_state.value.viewerSource != StoryViewerSource.ACTIVE) return val chatId = _state.value.chatId ?: return scope.launch { if (storyRepository.setChatActiveStoriesList(chatId, StoryListType.MAIN)) { @@ -574,6 +703,69 @@ class DefaultStoriesHostComponent( } } + override fun toggleCurrentStoryPostedToProfile() { + val current = _state.value + val story = current.currentStory ?: return + if (!story.canToggleIsPostedToChatPage) return + + val targetValue = !story.isPostedToChatPage + scope.launch { + val toggled = storyRepository.toggleStoryPostedToChatPage( + chatId = story.posterChatId, + storyId = story.id, + isPostedToChatPage = targetValue + ) + if (!toggled) { + _state.value = _state.value.copy( + inlineError = if (targetValue) { + "Failed to keep story on profile" + } else { + "Failed to remove story from profile" + } + ) + return@launch + } + + val updatedStory = storyRepository.getStory(story.posterChatId, story.id) ?: story.copy( + isPostedToChatPage = targetValue + ) + + if (current.viewerSource == StoryViewerSource.PROFILE && !updatedStory.isPostedToChatPage) { + val remaining = current.viewerItems.filterNot { + it.chatId == story.posterChatId && it.storyId == story.id + } + if (remaining.isEmpty()) { + dismiss() + } else { + val nextIndex = current.viewerIndex.coerceAtMost(remaining.lastIndex) + _state.value = + _state.value.copy(viewerItems = remaining, viewerIndex = nextIndex) + openStoryAt(nextIndex) + } + } else if ( + current.viewerSource == StoryViewerSource.PROFILE_ARCHIVE && + updatedStory.isPostedToChatPage + ) { + val remaining = current.viewerItems.filterNot { + it.chatId == story.posterChatId && it.storyId == story.id + } + if (remaining.isEmpty()) { + dismiss() + } else { + val nextIndex = current.viewerIndex.coerceAtMost(remaining.lastIndex) + _state.value = + _state.value.copy(viewerItems = remaining, viewerIndex = nextIndex) + openStoryAt(nextIndex) + } + } else { + _state.value = _state.value.copy( + currentStory = updatedStory, + inlineError = null + ) + } + } + } + override fun showStoryStatistics() { val story = _state.value.currentStory ?: return if (!story.canGetStatistics) return @@ -1096,9 +1288,24 @@ class DefaultStoriesHostComponent( ) } + private fun reopenStory( + chatId: Long, + storyId: Int, + viewerSource: StoryViewerSource, + activeListType: StoryListType + ) { + when (viewerSource) { + StoryViewerSource.ACTIVE -> openChatStories(chatId, storyId, activeListType) + StoryViewerSource.PROFILE -> openProfileStories(chatId, storyId) + StoryViewerSource.PROFILE_ARCHIVE -> openProfileStoryArchive(chatId, storyId) + StoryViewerSource.ALBUM -> openChatStories(chatId, storyId, activeListType) + } + } + companion object { private const val TAG = "StoriesHostDiag" private const val STORY_INTERACTIONS_PAGE_SIZE = 50 + private const val PROFILE_STORIES_PAGE_SIZE = 50 } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt index 5550bb449..d23819d9e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt @@ -25,6 +25,8 @@ interface StoriesHostComponent { ) fun openStoryAlbum(chatId: Long, albumId: Int) + fun openProfileStories(chatId: Long, storyId: Int? = null) + fun openProfileStoryArchive(chatId: Long, storyId: Int? = null) fun openComposer( chatId: Long, preferredMediaType: StoryMediaType? = null, @@ -53,6 +55,7 @@ interface StoriesHostComponent { fun deleteCurrentStory() fun moveCurrentStoryToArchive() fun restoreCurrentStoryFromArchive() + fun toggleCurrentStoryPostedToProfile() fun showStoryStatistics() fun dismissStoryStatistics() fun showStoryInteractions() @@ -81,6 +84,7 @@ interface StoriesHostComponent { val viewerItems: List = emptyList(), val viewerIndex: Int = 0, val currentStory: StoryModel? = null, + val viewerSource: StoryViewerSource = StoryViewerSource.ACTIVE, val activeListType: StoryListType = StoryListType.MAIN, val stealthMode: StoryStealthModeModel = StoryStealthModeModel(), val storyOptions: StoryOptionsModel = StoryOptionsModel(), @@ -123,6 +127,13 @@ interface StoriesHostComponent { } } +enum class StoryViewerSource { + ACTIVE, + PROFILE, + PROFILE_ARCHIVE, + ALBUM +} + enum class StoryComposerMode { CREATE, EDIT diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt index de61c8774..f89094dbb 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt @@ -77,6 +77,17 @@ import java.util.Date import kotlin.math.absoluteValue import kotlin.math.roundToInt +internal val StoryViewerContentColor = Color(0xFFF5F7FB) +internal val StoryViewerMutedContentColor = Color(0xB8F5F7FB) +internal val StoryViewerBorderColor = Color(0x26F5F7FB) +internal val StoryViewerSheetColor = Color(0xFF12161F) +internal val StoryViewerSheetSurfaceColor = Color(0xFF1A2028) +internal val StoryViewerSheetSurfaceVariantColor = Color(0xFF262D3A) + +internal fun storyViewerOverlayColor(alpha: Float = 0.78f): Color { + return Color(0xFF12161F).copy(alpha = alpha) +} + @Composable fun StoriesHostContent(component: StoriesHostComponent) { val state by component.state.collectAsState() @@ -167,7 +178,7 @@ private fun StoryViewerOverlay( Surface( modifier = Modifier.fillMaxSize(), color = Color.Black, - contentColor = MaterialTheme.colorScheme.onPrimary + contentColor = StoryViewerContentColor ) { Box(modifier = Modifier.fillMaxSize()) { if ( @@ -219,6 +230,7 @@ internal fun StoryViewerChrome( onProfileClick: (() -> Unit)?, onLinks: () -> Unit, onEdit: () -> Unit, + onToggleProfileVisibility: () -> Unit, onArchive: () -> Unit, onRestore: () -> Unit, onStatistics: () -> Unit, @@ -246,6 +258,7 @@ internal fun StoryViewerChrome( onProfileClick = onProfileClick, onLinks = onLinks, onEdit = onEdit, + onToggleProfileVisibility = onToggleProfileVisibility, onArchive = onArchive, onRestore = onRestore, onStatistics = onStatistics, @@ -318,7 +331,7 @@ internal fun StoryInteractionAvatar( ) { Surface( shape = CircleShape, - color = MaterialTheme.colorScheme.surfaceContainerHigh, + color = StoryViewerSheetSurfaceVariantColor, modifier = Modifier.size(40.dp) ) { if (avatarPath != null) { @@ -336,7 +349,7 @@ internal fun StoryInteractionAvatar( Icon( imageVector = if (isChat) Icons.Rounded.PeopleAlt else Icons.Rounded.Person, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = StoryViewerMutedContentColor ) } } @@ -359,9 +372,9 @@ internal fun StoryMetadataChip( ) }, colors = AssistChipDefaults.assistChipColors( - disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.7f), - disabledLabelColor = MaterialTheme.colorScheme.onSurface, - disabledLeadingIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant + disabledContainerColor = StoryViewerSheetSurfaceVariantColor.copy(alpha = 0.82f), + disabledLabelColor = StoryViewerContentColor, + disabledLeadingIconContentColor = StoryViewerMutedContentColor ), border = AssistChipDefaults.assistChipBorder( enabled = false, @@ -504,6 +517,8 @@ internal enum class StoryViewerMenuAction { COPY_STORY_LINK, LINKS, EDIT, + KEEP_ON_PROFILE, + REMOVE_FROM_PROFILE, ARCHIVE, RESTORE, STATISTICS, @@ -525,7 +540,7 @@ internal fun StoryTopIconButton( Surface( onClick = onClick, shape = CircleShape, - color = Color.Black.copy(alpha = 0.34f) + color = storyViewerOverlayColor(alpha = 0.82f) ) { Box( modifier = Modifier @@ -742,7 +757,8 @@ internal fun resolveStoryDownloadPath(story: StoryModel?): String? { internal fun buildStoryViewerMenuActions( story: StoryModel?, canManageStories: Boolean, - activeListType: StoryListType + activeListType: StoryListType, + viewerSource: StoryViewerSource ): List { if (story == null) return emptyList() @@ -785,7 +801,19 @@ internal fun buildStoryViewerMenuActions( ) ) } - if (canManageStories) { + if (story.canToggleIsPostedToChatPage) { + add( + StoryViewerMenuItem( + action = if (story.isPostedToChatPage) { + StoryViewerMenuAction.REMOVE_FROM_PROFILE + } else { + StoryViewerMenuAction.KEEP_ON_PROFILE + }, + group = StoryViewerMenuGroup.MANAGE + ) + ) + } + if (canManageStories && viewerSource == StoryViewerSource.ACTIVE) { add( StoryViewerMenuItem( action = if (activeListType == StoryListType.MAIN) { @@ -834,6 +862,8 @@ internal fun storyViewerMenuTitle( StoryViewerMenuAction.COPY_STORY_LINK -> stringResource(R.string.action_copy_link) StoryViewerMenuAction.LINKS -> stringResource(R.string.story_links_title) StoryViewerMenuAction.EDIT -> stringResource(R.string.action_edit) + StoryViewerMenuAction.KEEP_ON_PROFILE -> stringResource(R.string.story_keep_on_profile) + StoryViewerMenuAction.REMOVE_FROM_PROFILE -> stringResource(R.string.story_remove_from_profile) StoryViewerMenuAction.ARCHIVE -> stringResource(R.string.story_archive) StoryViewerMenuAction.RESTORE -> stringResource(R.string.action_restore) StoryViewerMenuAction.STATISTICS -> stringResource(R.string.story_statistics_title) @@ -850,6 +880,8 @@ internal fun storyViewerMenuIcon( StoryViewerMenuAction.COPY_STORY_LINK -> Icons.Rounded.Link StoryViewerMenuAction.LINKS -> Icons.Rounded.Link StoryViewerMenuAction.EDIT -> Icons.Rounded.Edit + StoryViewerMenuAction.KEEP_ON_PROFILE -> Icons.Rounded.Person + StoryViewerMenuAction.REMOVE_FROM_PROFILE -> Icons.Rounded.Person StoryViewerMenuAction.ARCHIVE -> Icons.Rounded.Archive StoryViewerMenuAction.RESTORE -> Icons.Rounded.Restore StoryViewerMenuAction.STATISTICS -> Icons.Rounded.BarChart diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt index bccdfde3e..0da4c508d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt @@ -119,6 +119,7 @@ internal fun StoryViewerChromeComponent( onProfileClick: (() -> Unit)?, onLinks: () -> Unit, onEdit: () -> Unit, + onToggleProfileVisibility: () -> Unit, onArchive: () -> Unit, onRestore: () -> Unit, onStatistics: () -> Unit, @@ -175,6 +176,7 @@ internal fun StoryViewerChromeComponent( stealthMode = state.stealthMode, storyOptions = state.storyOptions, canManageStories = state.canManageStories, + viewerSource = state.viewerSource, activeListType = state.activeListType, isMediaScaledToFill = isMediaScaledToFill, onDismiss = { showMenu = false }, @@ -184,6 +186,7 @@ internal fun StoryViewerChromeComponent( onCopyStoryLink = onCopyStoryLink, onLinks = onLinks, onEdit = onEdit, + onToggleProfileVisibility = onToggleProfileVisibility, onArchive = onArchive, onRestore = onRestore, onStatistics = onStatistics, @@ -208,7 +211,7 @@ internal fun StoryViewerChromeComponent( CircularProgressIndicator( modifier = Modifier.size(20.dp), strokeWidth = 2.5.dp, - color = MaterialTheme.colorScheme.onPrimary + color = StoryViewerContentColor ) } else { Icon( @@ -218,7 +221,7 @@ internal fun StoryViewerChromeComponent( Icons.Rounded.Pause }, contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary + tint = StoryViewerContentColor ) } } @@ -242,7 +245,7 @@ internal fun StoryViewerChromeComponent( Icons.AutoMirrored.Rounded.VolumeOff }, contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary + tint = StoryViewerContentColor ) } } @@ -260,7 +263,7 @@ internal fun StoryViewerChromeComponent( Icon( imageVector = Icons.Rounded.Favorite, contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary + tint = StoryViewerContentColor ) } } @@ -277,7 +280,7 @@ internal fun StoryViewerChromeComponent( Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), - color = Color.Black.copy(alpha = 0.38f) + color = storyViewerOverlayColor(alpha = 0.88f) ) { Text( text = story?.caption.orEmpty(), @@ -285,7 +288,7 @@ internal fun StoryViewerChromeComponent( .fillMaxWidth() .padding(horizontal = 14.dp, vertical = 12.dp), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimary + color = StoryViewerContentColor ) } } @@ -329,7 +332,7 @@ private fun StoryViewerHeader( Surface( shape = RoundedCornerShape(22.dp), - color = Color.Black.copy(alpha = 0.30f) + color = storyViewerOverlayColor(alpha = 0.84f) ) { Row( modifier = Modifier @@ -349,9 +352,11 @@ private fun StoryViewerHeader( ) { Surface( shape = CircleShape, - color = if (showSkeleton) Color.White.copy(alpha = 0.16f) else Color.White.copy( - alpha = 0.12f - ), + color = if (showSkeleton) { + StoryViewerContentColor.copy(alpha = 0.16f) + } else { + StoryViewerContentColor.copy(alpha = 0.12f) + }, modifier = Modifier.size(38.dp) ) { if (showSkeleton) { @@ -371,7 +376,7 @@ private fun StoryViewerHeader( Icon( imageVector = Icons.Rounded.Image, contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.9f) + tint = StoryViewerContentColor.copy(alpha = 0.9f) ) } } @@ -393,7 +398,7 @@ private fun StoryViewerHeader( Text( text = currentInfo.title, style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onPrimary, + color = StoryViewerContentColor, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -403,7 +408,7 @@ private fun StoryViewerHeader( currentInfo.postedAt.takeIf { it.isNotBlank() } ).joinToString(" • "), style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.72f), + color = StoryViewerMutedContentColor, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -418,7 +423,7 @@ private fun StoryViewerHeader( Icon( imageVector = Icons.Rounded.MoreVert, contentDescription = stringResource(R.string.menu_more), - tint = MaterialTheme.colorScheme.onPrimary + tint = StoryViewerContentColor ) } } @@ -427,7 +432,7 @@ private fun StoryViewerHeader( Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary + tint = StoryViewerContentColor ) } } @@ -443,6 +448,7 @@ private fun StoryViewerActionsPopup( stealthMode: org.monogram.domain.models.stories.StoryStealthModeModel, storyOptions: org.monogram.domain.models.stories.StoryOptionsModel, canManageStories: Boolean, + viewerSource: StoryViewerSource, activeListType: StoryListType, isMediaScaledToFill: Boolean, onDismiss: () -> Unit, @@ -452,6 +458,7 @@ private fun StoryViewerActionsPopup( onCopyStoryLink: () -> Unit, onLinks: () -> Unit, onEdit: () -> Unit, + onToggleProfileVisibility: () -> Unit, onArchive: () -> Unit, onRestore: () -> Unit, onStatistics: () -> Unit, @@ -484,11 +491,12 @@ private fun StoryViewerActionsPopup( nowSeconds = nowSeconds ) } - val menuActions = remember(story, canManageStories, activeListType) { + val menuActions = remember(story, canManageStories, activeListType, viewerSource) { buildStoryViewerMenuActions( story = story, canManageStories = canManageStories, - activeListType = activeListType + activeListType = activeListType, + viewerSource = viewerSource ) } @@ -509,7 +517,7 @@ private fun StoryViewerActionsPopup( if (stealthAvailability != StoryStealthAvailability.HIDDEN) { HorizontalDivider( modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) + color = StoryViewerBorderColor ) val remainingValue = when (stealthAvailability) { StoryStealthAvailability.ACTIVE -> { @@ -562,7 +570,7 @@ private fun StoryViewerActionsPopup( if (menuActions.isNotEmpty()) { HorizontalDivider( modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) + color = StoryViewerBorderColor ) } @@ -570,7 +578,7 @@ private fun StoryViewerActionsPopup( if (index > 0 && menuActions[index - 1].group != item.group) { HorizontalDivider( modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) + color = StoryViewerBorderColor ) } MenuOptionRow( @@ -585,6 +593,8 @@ private fun StoryViewerActionsPopup( StoryViewerMenuAction.COPY_STORY_LINK -> onCopyStoryLink() StoryViewerMenuAction.LINKS -> onLinks() StoryViewerMenuAction.EDIT -> onEdit() + StoryViewerMenuAction.KEEP_ON_PROFILE, + StoryViewerMenuAction.REMOVE_FROM_PROFILE -> onToggleProfileVisibility() StoryViewerMenuAction.ARCHIVE -> onArchive() StoryViewerMenuAction.RESTORE -> onRestore() StoryViewerMenuAction.STATISTICS -> onStatistics() @@ -623,7 +633,8 @@ internal fun StoryLinksSheetComponent( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), dragHandle = { BottomSheetDefaults.DragHandle() }, - containerColor = MaterialTheme.colorScheme.surface + containerColor = StoryViewerSheetColor, + contentColor = StoryViewerContentColor ) { Column( modifier = Modifier @@ -666,7 +677,7 @@ internal fun StoryLinksSheetComponent( if (index < urls.lastIndex) { HorizontalDivider( modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + color = StoryViewerBorderColor ) } } @@ -690,7 +701,8 @@ internal fun StoryInteractionsSheetComponent( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), dragHandle = { BottomSheetDefaults.DragHandle() }, - containerColor = MaterialTheme.colorScheme.surface + containerColor = StoryViewerSheetColor, + contentColor = StoryViewerContentColor ) { Column( modifier = Modifier @@ -756,7 +768,7 @@ internal fun StoryInteractionsSheetComponent( Text( text = stringResource(R.string.story_interactions_empty), style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = StoryViewerMutedContentColor ) } } @@ -810,7 +822,7 @@ internal fun StoryInteractionsSheetComponent( if (index < page.interactions.lastIndex) { HorizontalDivider( modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + color = StoryViewerBorderColor ) } } @@ -864,8 +876,8 @@ internal fun StoryReactionPickerSheetComponent( onDismissRequest = { showEmojiBrowser = false }, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), dragHandle = { BottomSheetDefaults.DragHandle() }, - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onSurface, + containerColor = StoryViewerSheetColor, + contentColor = StoryViewerContentColor, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) ) { StickerEmojiMenu( @@ -891,8 +903,8 @@ internal fun StoryReactionPickerSheetComponent( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), dragHandle = { BottomSheetDefaults.DragHandle() }, - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onSurface, + containerColor = StoryViewerSheetColor, + contentColor = StoryViewerContentColor, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) ) { Column( @@ -970,7 +982,7 @@ internal fun StoryReactionPickerSheetComponent( Text( text = stringResource(R.string.story_reaction_picker_empty), style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = StoryViewerMutedContentColor ) } } @@ -984,7 +996,7 @@ internal fun StoryReactionPickerSheetComponent( Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow + color = StoryViewerSheetSurfaceColor ) { Column( modifier = Modifier @@ -996,7 +1008,7 @@ internal fun StoryReactionPickerSheetComponent( modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = StoryViewerMutedContentColor, textAlign = TextAlign.Start ) Spacer(modifier = Modifier.height(12.dp)) @@ -1095,9 +1107,9 @@ private fun StoryReactionPickerCustomEmojiButton( color = if (isSelected) { MaterialTheme.colorScheme.primaryContainer } else if (enabled) { - MaterialTheme.colorScheme.surfaceContainerLow + StoryViewerSheetSurfaceColor } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f) + StoryViewerSheetSurfaceVariantColor.copy(alpha = 0.72f) } ) { Row( @@ -1110,7 +1122,7 @@ private fun StoryReactionPickerCustomEmojiButton( color = if (isSelected) { MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) } else { - MaterialTheme.colorScheme.surfaceContainerHigh + StoryViewerSheetSurfaceVariantColor } ) { Box( @@ -1141,7 +1153,7 @@ private fun StoryReactionPickerCustomEmojiButton( color = if (isSelected) { MaterialTheme.colorScheme.onPrimaryContainer } else { - MaterialTheme.colorScheme.onSurface + StoryViewerContentColor } ) if (isSelected) { @@ -1154,7 +1166,7 @@ private fun StoryReactionPickerCustomEmojiButton( Text( text = stringResource(R.string.story_reaction_picker_custom_emoji_locked), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = StoryViewerMutedContentColor ) } } @@ -1171,7 +1183,7 @@ private fun StoryReactionPickerExpandButton( Surface( onClick = onClick, shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, + color = StoryViewerSheetSurfaceVariantColor, modifier = Modifier.fillMaxWidth() ) { Box( @@ -1185,7 +1197,7 @@ private fun StoryReactionPickerExpandButton( "${stringResource(R.string.action_show_more)}${if (hiddenCount > 0) " ($hiddenCount)" else ""}" }, style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = StoryViewerMutedContentColor, textAlign = TextAlign.Center ) } @@ -1209,9 +1221,9 @@ private fun StoryReactionPickerChip( color = if (selected) { MaterialTheme.colorScheme.primaryContainer } else if (enabled) { - MaterialTheme.colorScheme.surfaceContainerHigh + StoryViewerSheetSurfaceVariantColor } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + StoryViewerSheetSurfaceVariantColor.copy(alpha = 0.72f) } ) { Box( @@ -1234,7 +1246,7 @@ private fun StoryReactionPickerChip( color = if (selected) { MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.82f) } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.82f) + StoryViewerMutedContentColor.copy(alpha = 0.82f) }, textAlign = TextAlign.Center ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt index 6105820e7..eb21dbc13 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt @@ -239,6 +239,7 @@ internal fun StoryViewerScaffoldComponent( onProfileClick = state.chatId?.let { chatId -> { component.openProfile(chatId) } }, onLinks = { isLinksSheetVisible = true }, onEdit = component::editCurrentStory, + onToggleProfileVisibility = component::toggleCurrentStoryPostedToProfile, onArchive = component::moveCurrentStoryToArchive, onRestore = component::restoreCurrentStoryFromArchive, onStatistics = component::showStoryStatistics, @@ -670,7 +671,7 @@ private fun StoryAreaChip( if (isSelected) { MaterialTheme.colorScheme.primary.copy(alpha = 0.95f) } else { - Color.White.copy(alpha = 0.14f) + StoryViewerBorderColor } ) ) { @@ -681,7 +682,10 @@ private fun StoryAreaChip( .align(Alignment.TopEnd) .padding(top = 6.dp, end = 6.dp) .size(14.dp) - .background(Color.White.copy(alpha = 0.18f), CircleShape), + .background( + StoryViewerContentColor.copy(alpha = 0.18f), + CircleShape + ), contentAlignment = Alignment.Center ) { Icon( @@ -720,7 +724,7 @@ private fun StoryAreaChip( .then(if (isClickable) Modifier.clickable(onClick = onClick) else Modifier), shape = RoundedCornerShape(cornerRadius), color = backgroundColor, - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)) + border = BorderStroke(1.dp, StoryViewerBorderColor) ) { Row( modifier = Modifier @@ -802,13 +806,13 @@ internal fun StoryUnavailablePlaceholder(message: String) { ) { Surface( shape = RoundedCornerShape(30.dp), - color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.82f) + color = storyViewerOverlayColor(alpha = 0.92f) ) { Text( text = message, modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + color = StoryViewerContentColor, textAlign = TextAlign.Center ) } @@ -872,10 +876,10 @@ private fun StoryMediaWaitingPlaceholder( Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.20f)), + .background(storyViewerOverlayColor(alpha = 0.54f)), contentAlignment = Alignment.Center ) { - CircularProgressIndicator(color = MaterialTheme.colorScheme.onPrimary) + CircularProgressIndicator(color = StoryViewerContentColor) } if (showLoadingText) { StoryMediaLoadingBadge( @@ -895,13 +899,13 @@ private fun StoryMediaLoadingOverlay( Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.10f)), + .background(storyViewerOverlayColor(alpha = 0.42f)), contentAlignment = Alignment.Center ) { CircularProgressIndicator( modifier = Modifier.size(28.dp), strokeWidth = 2.5.dp, - color = MaterialTheme.colorScheme.onPrimary + color = StoryViewerContentColor ) if (showLoadingText) { StoryMediaLoadingBadge( @@ -939,15 +943,15 @@ private fun StoryMediaLoadingBadge( .matchParentSize() .blur(18.dp) .background( - Color.Black.copy(alpha = pulseAlpha), + storyViewerOverlayColor(alpha = pulseAlpha), RoundedCornerShape(22.dp) ) ) Surface( modifier = Modifier.animateContentSize(), shape = RoundedCornerShape(if (showText) 22.dp else 18.dp), - color = Color.Black.copy(alpha = 0.30f), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)) + color = storyViewerOverlayColor(alpha = 0.88f), + border = BorderStroke(1.dp, StoryViewerBorderColor) ) { Row( modifier = Modifier.padding( @@ -960,13 +964,13 @@ private fun StoryMediaLoadingBadge( CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary + color = StoryViewerContentColor ) if (showText) { Text( text = stringResource(R.string.story_media_loading), style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.92f) + color = StoryViewerContentColor.copy(alpha = 0.92f) ) } } @@ -983,8 +987,8 @@ private fun StoryStatusBarScrim() { .background( Brush.verticalGradient( colors = listOf( - Color.Black.copy(alpha = 0.50f), - Color.Black.copy(alpha = 0.24f), + storyViewerOverlayColor(alpha = 0.94f), + storyViewerOverlayColor(alpha = 0.42f), Color.Transparent ) ) @@ -1015,14 +1019,14 @@ internal fun StoryViewerProgressRow( .weight(1f) .height(4.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.22f)) + .background(StoryViewerContentColor.copy(alpha = 0.22f)) ) { Box( modifier = Modifier .fillMaxHeight() .fillMaxWidth(progress) .clip(CircleShape) - .background(MaterialTheme.colorScheme.onPrimary) + .background(StoryViewerContentColor) ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt index 742c87105..779f24c0d 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt @@ -841,11 +841,11 @@ class DefaultRootComponent( onOpenStoriesClicked = { targetChatId, storyId -> storiesHost.openChatStories(targetChatId, storyId) }, + onOpenPostedStoriesClicked = { targetChatId, storyId -> + storiesHost.openProfileStories(targetChatId, storyId) + }, onOpenStoryArchiveClicked = { targetChatId -> - storiesHost.openChatStories( - targetChatId, - listType = org.monogram.domain.models.stories.StoryListType.ARCHIVE - ) + storiesHost.openProfileStoryArchive(targetChatId) }, onCreateStoryClicked = { targetChatId -> openStoryComposer(targetChatId) diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index d7beb018b..959714b70 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -328,6 +328,8 @@ Админ Истории профиля Вы можете публиковать истории от своего профиля + Активные + Текущие и скрытые истории Фон чата Вы можете устанавливать пользовательские фоны Голосовые и видеосообщения @@ -1338,6 +1340,7 @@ Открыть с помощью Браузер / Другое Медиа + Истории Участники Файлы Музыка @@ -1346,6 +1349,7 @@ GIF Участники не найдены Медиа не найдены + Истории не найдены Аудиозаписи не найдены Голосовые сообщения не найдены Файлы не найдены @@ -2412,6 +2416,7 @@ Сохраняем… История недоступна Открыть истории + Открыть истории профиля Создать историю Архив Выключить звук истории @@ -2439,6 +2444,7 @@ Нажмите, чтобы открыть %1$s %2$d° Активных историй: %1$d + Историй в профиле: %1$d %1$d из %2$d Публикация от имени %1$s Редактирование от имени %1$s @@ -2462,6 +2468,7 @@ Настройки истории Выберите, как долго эта история будет видна Показывать эту историю в вашем профиле после публикации + Убрать из профиля Запретить пользователям пересылать и сохранять историю Прикреплённый виджет Эта история содержит ссылку на мини-приложение diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 11c573bca..132fbd62c 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -565,6 +565,8 @@ Admin Profile stories You can publish stories from your profile + Active + Current and hidden stories New story Edit story Pick @@ -586,6 +588,7 @@ Saving… Story unavailable Open stories + Open profile stories Create story Archive Mute story audio @@ -613,6 +616,7 @@ Tap to open %1$s %2$d° %1$d active stories + %1$d profile stories %1$d of %2$d Posting as %1$s Editing as %1$s @@ -636,6 +640,7 @@ Story settings Pick how long this story stays visible Show this story on your profile after publishing + Remove from profile Restrict forwarding and saving for viewers Attached widget This story includes a mini app link @@ -1917,6 +1922,7 @@ Open with Browser / Other Media + Stories Members Files Audio @@ -1925,6 +1931,7 @@ GIFs No members found No media found + No stories found No audio found No voice messages found No files found From a70abbdd428f07e4a535a4d2eb73777ced45eb1e Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:15:11 +0300 Subject: [PATCH 14/16] stories: Fix false trigger to update stories list --- .../chats/list/DefaultChatListComponent.kt | 95 ++++++++++++++++--- .../list/FolderChatsNormalizationTest.kt | 81 +++++++++++++++- 2 files changed, 161 insertions(+), 15 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt index 3f70a852a..26e64790e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt @@ -138,6 +138,7 @@ class DefaultChatListComponent( private val prefetchSemaphore = Semaphore(PREFETCH_CONCURRENCY) private val messagePrefetchTimestamps = mutableMapOf() private val storyPrefetchChatIds = mutableSetOf() + private val lastPinnedFolderLogByFolder = mutableMapOf() private fun ChatListComponent.State.toUiState() = ChatListComponent.UiState( currentUser = currentUser, @@ -307,15 +308,26 @@ class DefaultChatListComponent( chatFolderRepository.folderChatsFlow .onEach { update -> val normalizedList = normalizeFolderChats(update.chats) - val pinnedCount = normalizedList.count { it.isPinned } - if (pinnedCount > 0) { + val trackedStoryChatIds = storyRepository.activeStories.value.values + .asSequence() + .flatMap(List::asSequence) + .mapTo(mutableSetOf()) { it.chatId } + val shouldRefreshStories = shouldRefreshStoriesForFolderUpdate( + previousChats = _state.value.chatsByFolder[update.folderId], + updatedChats = normalizedList, + trackedStoryChatIds = trackedStoryChatIds + ) + + val pinnedSnapshot = normalizedList.toPinnedFolderSnapshot() + if (pinnedSnapshot != null && lastPinnedFolderLogByFolder[update.folderId] != pinnedSnapshot) { + lastPinnedFolderLogByFolder[update.folderId] = pinnedSnapshot Log.d( TAG, - "folder=${update.folderId} chats=${normalizedList.size} pinned=$pinnedCount pinnedIds=${ - normalizedList.filter { it.isPinned }.take(10) - .joinToString { it.id.toString() } - }" + "folder=${update.folderId} chats=${normalizedList.size} pinned=${pinnedSnapshot.pinnedIds.size} " + + "pinnedIds=${pinnedSnapshot.pinnedIds.take(10).joinToString()}" ) + } else if (pinnedSnapshot == null) { + lastPinnedFolderLogByFolder.remove(update.folderId) } _state.update { if (it.chatsByFolder[update.folderId] == normalizedList) { @@ -327,7 +339,9 @@ class DefaultChatListComponent( } syncProjectChannelSubscriptionFromChats(normalizedList) prefetchStoryListsForChats(update.folderId, normalizedList) - refreshStoriesState("folder_${update.folderId}") + if (shouldRefreshStories) { + refreshStoriesState("folder_${update.folderId}") + } } .launchIn(scope) @@ -1361,17 +1375,23 @@ class DefaultChatListComponent( val isArchiveStoriesLoaded = storyListChatCounts.containsKey(StoryListType.ARCHIVE) || repositoryStories.containsKey(StoryListType.ARCHIVE) - Log.d( - STORY_TAG, - "refreshStoriesState reason=$reason total=${combinedStories.size} main=${mainStories.size} archived=${archiveStories.size} archiveFolderIds=${archiveFolderChatIds.size} mainLoaded=$isMainStoriesLoaded archiveLoaded=$isArchiveStoriesLoaded" - ) - - _storiesState.value = ChatListComponent.StoriesState( + val newStoriesState = ChatListComponent.StoriesState( mainActiveStories = mainStories, archiveActiveStories = archiveStories, isMainStoriesLoaded = isMainStoriesLoaded, isArchiveStoriesLoaded = isArchiveStoriesLoaded ) + + if (newStoriesState == _storiesState.value) { + return@launch + } + + Log.d( + STORY_TAG, + "refreshStoriesState reason=$reason total=${combinedStories.size} main=${mainStories.size} archived=${archiveStories.size} archiveFolderIds=${archiveFolderChatIds.size} mainLoaded=$isMainStoriesLoaded archiveLoaded=$isArchiveStoriesLoaded" + ) + + _storiesState.value = newStoriesState } } @@ -1592,6 +1612,55 @@ internal fun normalizeFolderChats(chats: List): List { return if (normalized.size == chats.size) chats else normalized } +internal fun shouldRefreshStoriesForFolderUpdate( + previousChats: List?, + updatedChats: List, + trackedStoryChatIds: Set +): Boolean { + return previousChats.toStoryRefreshSnapshot(trackedStoryChatIds) != + updatedChats.toStoryRefreshSnapshot(trackedStoryChatIds) +} + +private fun List?.toStoryRefreshSnapshot( + trackedStoryChatIds: Set +): Map { + return this.orEmpty() + .asSequence() + .filter { chat -> + chat.id in trackedStoryChatIds || + chat.isArchived || + chat.activeStoryId != 0 || + !chat.activeStoryStateType.isNullOrBlank() + } + .associate { chat -> + chat.id to StoryRefreshSnapshot( + id = chat.id, + isArchived = chat.isArchived, + activeStoryStateType = chat.activeStoryStateType, + activeStoryId = chat.activeStoryId + ) + } +} + +private data class StoryRefreshSnapshot( + val id: Long, + val isArchived: Boolean, + val activeStoryStateType: String?, + val activeStoryId: Int +) + +private data class PinnedFolderSnapshot( + val pinnedIds: List +) + +private fun List.toPinnedFolderSnapshot(): PinnedFolderSnapshot? { + val pinnedIds = asSequence() + .filter { it.isPinned } + .map { it.id } + .toList() + return pinnedIds.takeIf(List::isNotEmpty)?.let(::PinnedFolderSnapshot) +} + private fun AppPreferences.projectChannelSubscriptionState(): ChatListComponent.ProjectChannelSubscriptionState { return isProjectChannelSubscribed.value.toProjectChannelSubscriptionState() } diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/list/FolderChatsNormalizationTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/list/FolderChatsNormalizationTest.kt index e8e2efb66..3d00bd868 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/list/FolderChatsNormalizationTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/list/FolderChatsNormalizationTest.kt @@ -1,7 +1,9 @@ package org.monogram.presentation.features.chats.list import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue import org.junit.Test import org.monogram.domain.models.ChatModel @@ -26,6 +28,81 @@ class FolderChatsNormalizationTest { assertSame(source, result) } - private fun chat(id: Long, order: Long = id): ChatModel = - ChatModel(id = id, title = "chat $id", order = order, unreadCount = 0) + @Test + fun `shouldRefreshStoriesForFolderUpdate ignores noisy changes for tracked story chats`() { + val previous = listOf( + chat( + id = 1L, + title = "before", + unreadCount = 0, + typingAction = null, + userStatus = "offline" + ) + ) + val updated = listOf( + chat( + id = 1L, + title = "after", + unreadCount = 7, + typingAction = "typing", + userStatus = "online" + ) + ) + + val result = shouldRefreshStoriesForFolderUpdate( + previousChats = previous, + updatedChats = updated, + trackedStoryChatIds = setOf(1L) + ) + + assertFalse(result) + } + + @Test + fun `shouldRefreshStoriesForFolderUpdate reacts to archive state changes for tracked stories`() { + val previous = listOf(chat(id = 1L, isArchived = false)) + val updated = listOf(chat(id = 1L, isArchived = true)) + + val result = shouldRefreshStoriesForFolderUpdate( + previousChats = previous, + updatedChats = updated, + trackedStoryChatIds = setOf(1L) + ) + + assertTrue(result) + } + + @Test + fun `shouldRefreshStoriesForFolderUpdate reacts to hinted story chats`() { + val previous = emptyList() + val updated = listOf(chat(id = 3L, activeStoryStateType = "READ")) + + val result = shouldRefreshStoriesForFolderUpdate( + previousChats = previous, + updatedChats = updated, + trackedStoryChatIds = emptySet() + ) + + assertTrue(result) + } + + private fun chat( + id: Long, + order: Long = id, + title: String = "chat $id", + unreadCount: Int = 0, + typingAction: String? = null, + userStatus: String? = null, + isArchived: Boolean = false, + activeStoryStateType: String? = null + ): ChatModel = ChatModel( + id = id, + title = title, + order = order, + unreadCount = unreadCount, + typingAction = typingAction, + userStatus = userStatus, + isArchived = isArchived, + activeStoryStateType = activeStoryStateType + ) } From 15008e01e43a33fdc6a9683c9f59fa3b53614a0f Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:35:20 +0300 Subject: [PATCH 15/16] stories: reduce chat list and stories update noise --- .../monogram/data/chats/ChatModelFactory.kt | 20 ++-- .../monogram/data/chats/ChatUpdateHandler.kt | 7 +- .../repository/ChatsListRepositoryImpl.kt | 93 ++++++++++++++++++- .../data/repository/StoryRepositoryImpl.kt | 23 ++--- .../repository/StoryRepositoryStateReducer.kt | 26 ++++-- .../features/chats/list/ChatListContent.kt | 37 +++++++- .../chats/list/DefaultChatListComponent.kt | 31 +++++-- 7 files changed, 183 insertions(+), 54 deletions(-) diff --git a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt index 1114f0744..46b1da0a4 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt @@ -35,7 +35,7 @@ class ChatModelFactory( private val appPreferences: AppPreferencesProvider, private val userFullInfoDao: UserFullInfoDao, private val muteResolver: NotificationMuteResolver = NotificationMuteResolver(), - private val triggerUpdate: (Long?) -> Unit, + private val scheduleUpdate: (Long) -> Unit, private val fetchUser: (Long) -> Unit ) { private val missingUserFullInfoUntilMs = ConcurrentHashMap() @@ -95,7 +95,7 @@ class ChatModelFactory( if (type.basicGroupId == 0L) return@lazyLoad val result = gateway.execute(TdApi.GetBasicGroup(type.basicGroupId)) cache.basicGroups[result.id] = result - triggerUpdate(chat.id) + scheduleUpdate(chat.id) } cache.basicGroupFullInfoCache[type.basicGroupId]?.let { fullInfo -> @@ -106,7 +106,7 @@ class ChatModelFactory( if (type.basicGroupId == 0L) return@lazyLoad val result = gateway.execute(TdApi.GetBasicGroupFullInfo(type.basicGroupId)) cache.basicGroupFullInfoCache[type.basicGroupId] = result - triggerUpdate(chat.id) + scheduleUpdate(chat.id) } } @@ -141,7 +141,7 @@ class ChatModelFactory( if (type.supergroupId == 0L) return@lazyLoad val result = gateway.execute(TdApi.GetSupergroup(type.supergroupId)) cache.supergroups[result.id] = result - triggerUpdate(chat.id) + scheduleUpdate(chat.id) } val canLoadSupergroupFullInfo = supergroup?.status?.let { @@ -157,7 +157,7 @@ class ChatModelFactory( if (type.supergroupId == 0L) return@lazyLoad val result = gateway.execute(TdApi.GetSupergroupFullInfo(type.supergroupId)) cache.supergroupFullInfoCache[type.supergroupId] = result - triggerUpdate(chat.id) + scheduleUpdate(chat.id) } } } @@ -202,7 +202,7 @@ class ChatModelFactory( lazyLoad(cache.pendingUserFullInfo, type.userId) { if (type.userId == 0L) return@lazyLoad cache.userFullInfoCache[type.userId]?.let { - triggerUpdate(chat.id) + scheduleUpdate(chat.id) return@lazyLoad } val cachedInfo = coRunCatching { @@ -211,7 +211,7 @@ class ChatModelFactory( if (cachedInfo != null) { cache.putUserFullInfo(type.userId, cachedInfo) missingUserFullInfoUntilMs.remove(type.userId) - triggerUpdate(chat.id) + scheduleUpdate(chat.id) return@lazyLoad } val result = userFullInfoSemaphore.withPermit { @@ -223,7 +223,7 @@ class ChatModelFactory( cache.putUserFullInfo(type.userId, result) coRunCatching { userFullInfoDao.insertUserFullInfo(result.toEntity(type.userId)) } missingUserFullInfoUntilMs.remove(type.userId) - triggerUpdate(chat.id) + scheduleUpdate(chat.id) } else { rememberMissingUserFullInfo(type.userId) } @@ -240,7 +240,7 @@ class ChatModelFactory( lazyLoad(cache.pendingChatPermissions, chat.id) { val result = gateway.execute(TdApi.GetChat(chat.id)) cache.chatPermissionsCache[chat.id] = result.permissions - triggerUpdate(chat.id) + scheduleUpdate(chat.id) } } @@ -260,7 +260,7 @@ class ChatModelFactory( TdApi.GetChatMember(chat.id, TdApi.MessageSenderUser(me.id)) ) cache.myChatMemberCache[chat.id] = member - triggerUpdate(chat.id) + scheduleUpdate(chat.id) } } } diff --git a/data/src/main/java/org/monogram/data/chats/ChatUpdateHandler.kt b/data/src/main/java/org/monogram/data/chats/ChatUpdateHandler.kt index af9c5a994..6ac37aa48 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatUpdateHandler.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatUpdateHandler.kt @@ -23,6 +23,7 @@ class ChatUpdateHandler( private val onSaveChatsBySupergroupId: (Long) -> Unit, private val onSaveChatsByBasicGroupId: (Long) -> Unit, private val onTriggerUpdate: (Long?) -> Unit, + private val onScheduleUpdate: (Long?) -> Unit, private val onRefreshChat: suspend (Long) -> Unit, private val onRefreshForumTopics: () -> Unit, private val onAuthorizationStateClosed: () -> Unit @@ -163,7 +164,7 @@ class ChatUpdateHandler( is TdApi.UpdateFile -> { if (fileManager.handleFileUpdate(update.file)) { val chatId = fileManager.getChatIdByPhotoId(update.file.id) - onTriggerUpdate(chatId) + onScheduleUpdate(chatId) onRefreshForumTopics() } } @@ -183,7 +184,7 @@ class ChatUpdateHandler( is TdApi.UpdateUserStatus -> { cache.updateUser(update.userId) { it.status = update.status } cache.userIdToChatId[update.userId]?.let { chatId -> - onTriggerUpdate(chatId) + onScheduleUpdate(chatId) } } @@ -281,7 +282,7 @@ class ChatUpdateHandler( is TdApi.UpdateChatOnlineMemberCount -> { cache.putOnlineMemberCount(update.chatId, update.onlineMemberCount) onSaveChat(update.chatId) - onTriggerUpdate(update.chatId) + onScheduleUpdate(update.chatId) } is TdApi.UpdateAuthorizationState -> { diff --git a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt index 5e55b7833..b75a88ee7 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -4,8 +4,11 @@ import android.util.Log import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -14,6 +17,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider import org.monogram.data.chats.ChatCache @@ -67,6 +72,8 @@ import kotlin.system.measureTimeMillis private const val RESUME_RECOVERY_COOLDOWN_MS_POLICY = 3_000L private const val MIN_EXPECTED_RENDERED_CHATS_POLICY = 20 +private const val LIGHTWEIGHT_UPDATE_DEBOUNCE_MS_POLICY = 150L +private const val BACKFILL_FETCH_PARALLELISM = 4 class ChatsListRepositoryImpl( private val remoteDataSource: ChatsRemoteDataSource, @@ -141,7 +148,7 @@ class ChatsListRepositoryImpl( usersCache = cache.usersCache, allChats = cache.allChats, stringProvider = stringProvider, - onUpdate = { chatId -> triggerUpdate(chatId) }, + onUpdate = { chatId -> scheduleUpdate(chatId) }, onUserNeeded = { userId -> fetchUser(userId) } ) @@ -164,7 +171,7 @@ class ChatsListRepositoryImpl( typingManager = typingManager, appPreferences = appPreferences, userFullInfoDao = userFullInfoDao, - triggerUpdate = { chatId -> triggerUpdate(chatId) }, + scheduleUpdate = { chatId -> scheduleUpdate(chatId) }, fetchUser = { userId -> fetchUser(userId) } ) @@ -240,6 +247,15 @@ class ChatsListRepositoryImpl( @Volatile private var lastConnectionStatus: ConnectionStatus? = null + private val scheduledUpdateLock = Any() + private val pendingScheduledChatIds = ConcurrentHashMap.newKeySet() + + @Volatile + private var pendingScheduledFullInvalidation = false + + @Volatile + private var scheduledUpdateJob: Job? = null + private val modelCache = SynchronizedLruMap(MODEL_CACHE_SIZE) private val invalidatedModels = ConcurrentHashMap.newKeySet() @Volatile @@ -262,6 +278,7 @@ class ChatsListRepositoryImpl( onSaveChatsBySupergroupId = { supergroupId -> persistenceManager.scheduleSavesBySupergroupId(supergroupId) }, onSaveChatsByBasicGroupId = { basicGroupId -> persistenceManager.scheduleSavesByBasicGroupId(basicGroupId) }, onTriggerUpdate = { chatId -> triggerUpdate(chatId) }, + onScheduleUpdate = { chatId -> scheduleUpdate(chatId) }, onRefreshChat = { chatId -> refreshChat(chatId) }, onRefreshForumTopics = { refreshActiveForumTopics() }, onAuthorizationStateClosed = { @@ -410,6 +427,12 @@ class ChatsListRepositoryImpl( } private fun clearTransientState() { + synchronized(scheduledUpdateLock) { + scheduledUpdateJob?.cancel() + scheduledUpdateJob = null + pendingScheduledFullInvalidation = false + pendingScheduledChatIds.clear() + } modelCache.clear() invalidatedModels.clear() invalidateAllModels = true @@ -429,6 +452,51 @@ class ChatsListRepositoryImpl( updateChannel.trySend(Unit) } + private fun scheduleUpdate(chatId: Long? = null) { + synchronized(scheduledUpdateLock) { + if (chatId == null) { + pendingScheduledFullInvalidation = true + } else { + pendingScheduledChatIds.add(chatId) + } + if (scheduledUpdateJob?.isActive == true) { + return + } + scheduledUpdateJob = scope.launch(dispatchers.io) { + while (true) { + delay(LIGHTWEIGHT_UPDATE_DEBOUNCE_MS_POLICY) + + val fullInvalidation: Boolean + val scheduledChatIds: Set + synchronized(scheduledUpdateLock) { + fullInvalidation = pendingScheduledFullInvalidation + scheduledChatIds = pendingScheduledChatIds.toSet() + pendingScheduledFullInvalidation = false + pendingScheduledChatIds.clear() + } + + if (fullInvalidation) { + invalidateAllModels = true + invalidatedModels.addAll(cache.activeListPositions.keys) + } else if (scheduledChatIds.isNotEmpty()) { + invalidatedModels.addAll(scheduledChatIds) + } + + if (fullInvalidation || scheduledChatIds.isNotEmpty()) { + updateChannel.trySend(Unit) + } + + synchronized(scheduledUpdateLock) { + if (!pendingScheduledFullInvalidation && pendingScheduledChatIds.isEmpty()) { + scheduledUpdateJob = null + return@launch + } + } + } + } + } + } + private fun isRequestActive(folderId: Int, requestId: Long): Boolean { return activeFolderId == folderId && activeRequestId == requestId } @@ -1150,6 +1218,7 @@ class ChatsListRepositoryImpl( ) var recovered = 0 + val missingIdsToFetch = ArrayList(missingIds.size) missingIds.forEach { chatId -> val cachedChat = cache.getChat(chatId) if (cachedChat != null && cachedChat.positions.any { pos -> @@ -1162,8 +1231,26 @@ class ChatsListRepositoryImpl( recovered += 1 return@forEach } + missingIdsToFetch += chatId + } + + val fetchedChatsById = coroutineScope { + val semaphore = Semaphore(BACKFILL_FETCH_PARALLELISM) + missingIdsToFetch.map { chatId -> + async(dispatchers.io) { + semaphore.withPermit { + if (!isRequestActive(folderId, requestId)) { + chatId to null + } else { + chatId to remoteDataSource.getChat(chatId) + } + } + } + }.awaitAll() + }.toMap() - val fetchedChat = remoteDataSource.getChat(chatId) ?: return@forEach + missingIdsToFetch.forEach { chatId -> + val fetchedChat = fetchedChatsById[chatId] ?: return@forEach val existingPositions = cache.getChat(fetchedChat.id)?.positions ?: emptyArray() fetchedChat.positions = listManager.sanitizePositionsForActiveList( chatId = fetchedChat.id, diff --git a/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt index 1bd5799bf..467afe9ec 100644 --- a/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt @@ -94,10 +94,7 @@ class StoryRepositoryImpl( } override suspend fun getChatActiveStories(chatId: Long): ActiveStoryListModel? { - val existing = state.value.activeStories.values - .asSequence() - .flatMap(List::asSequence) - .firstOrNull { it.chatId == chatId } + val existing = state.value.activeStoriesByChatId[chatId] if (existing != null) { Log.d( TAG, @@ -410,10 +407,7 @@ class StoryRepositoryImpl( } is TdApi.UpdateStory -> { - val active = state.value.activeStories.values - .asSequence() - .flatMap(List::asSequence) - .firstOrNull { it.chatId == update.story.posterChatId } + val active = state.value.activeStoriesByChatId[update.story.posterChatId] applyState( StoryRepositoryStateReducer.withStory( state.value, @@ -437,10 +431,7 @@ class StoryRepositoryImpl( } is TdApi.UpdateStoryPostSucceeded -> { - val active = state.value.activeStories.values - .asSequence() - .flatMap(List::asSequence) - .firstOrNull { it.chatId == update.story.posterChatId } + val active = state.value.activeStoriesByChatId[update.story.posterChatId] applyState( StoryRepositoryStateReducer.withPostSucceeded( state.value, @@ -455,10 +446,7 @@ class StoryRepositoryImpl( } is TdApi.UpdateStoryPostFailed -> { - val active = state.value.activeStories.values - .asSequence() - .flatMap(List::asSequence) - .firstOrNull { it.chatId == update.story.posterChatId } + val active = state.value.activeStoriesByChatId[update.story.posterChatId] applyState( StoryRepositoryStateReducer.withPostFailed( state.value, @@ -497,6 +485,9 @@ class StoryRepositoryImpl( } private fun applyState(newState: StoryRepositoryState) { + if (newState == state.value) { + return + } state.value = newState _activeStories.value = newState.activeStories _storyListChatCounts.value = newState.storyListChatCounts diff --git a/data/src/main/java/org/monogram/data/repository/StoryRepositoryStateReducer.kt b/data/src/main/java/org/monogram/data/repository/StoryRepositoryStateReducer.kt index 4322598b0..fd5459261 100644 --- a/data/src/main/java/org/monogram/data/repository/StoryRepositoryStateReducer.kt +++ b/data/src/main/java/org/monogram/data/repository/StoryRepositoryStateReducer.kt @@ -14,6 +14,7 @@ internal data class StoryKey( internal data class StoryRepositoryState( val activeStories: Map> = emptyMap(), + val activeStoriesByChatId: Map = emptyMap(), val storyCache: Map = emptyMap(), val storyListChatCounts: Map = emptyMap(), val stealthMode: StoryStealthModeModel = StoryStealthModeModel(), @@ -26,12 +27,14 @@ internal object StoryRepositoryStateReducer { state: StoryRepositoryState, active: ActiveStoryListModel ): StoryRepositoryState { - val currentList = state.activeStories[active.listType].orEmpty() - .filterNot { it.chatId == active.chatId } - val updatedList = (currentList + active) + val activeStoriesWithoutChat = state.activeStories.mapValues { (_, list) -> + list.filterNot { it.chatId == active.chatId } + }.filterValues { it.isNotEmpty() } + val updatedList = (activeStoriesWithoutChat[active.listType].orEmpty() + active) .sortedWith(compareByDescending { it.order }.thenByDescending { it.chatId }) return state.copy( - activeStories = state.activeStories + (active.listType to updatedList), + activeStories = activeStoriesWithoutChat + (active.listType to updatedList), + activeStoriesByChatId = state.activeStoriesByChatId + (active.chatId to active), storyCache = state.storyCache + active.stories.associate { summary -> val existing = state.storyCache[StoryKey(active.chatId, summary.storyId)] StoryKey(active.chatId, summary.storyId) to (existing?.copy(isRead = summary.isRead) @@ -92,8 +95,19 @@ internal object StoryRepositoryStateReducer { } } }.filterValues { it.isNotEmpty() } + val updatedActiveByChatId = state.activeStoriesByChatId.toMutableMap() + val updatedActive = updatedActiveByChatId[chatId] + if (updatedActive != null) { + val remaining = updatedActive.stories.filterNot { it.storyId == storyId } + if (remaining.isEmpty()) { + updatedActiveByChatId.remove(chatId) + } else { + updatedActiveByChatId[chatId] = updatedActive.copy(stories = remaining) + } + } return state.copy( activeStories = newActiveStories, + activeStoriesByChatId = updatedActiveByChatId, storyCache = state.storyCache - StoryKey(chatId, storyId) ) } @@ -151,8 +165,6 @@ internal object StoryRepositoryStateReducer { } private fun StoryRepositoryState.findActiveStories(chatId: Long): ActiveStoryListModel? { - return activeStories.values.asSequence() - .flatMap(List::asSequence) - .firstOrNull { it.chatId == chatId } + return activeStoriesByChatId[chatId] } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt index 12a1ebbba..1cf864b83 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt @@ -377,13 +377,18 @@ fun ChatListContent( val canShowArchive = isArchivePersistent || isMainView val currentFolderChats = foldersState.chatsByFolder[effectiveFoldersState.selectedFolderId].orEmpty() - val storyIndexChats = remember(foldersState.chatsByFolder) { - LinkedHashMap().apply { - foldersState.chatsByFolder.values.forEach { chats -> - chats.forEach { chat -> putIfAbsent(chat.id, chat) } - } + val storyChatIds = remember(storiesState.mainActiveStories, storiesState.archiveActiveStories) { + linkedSetOf().apply { + storiesState.mainActiveStories.forEach { add(it.chatId) } + storiesState.archiveActiveStories.forEach { add(it.chatId) } } } + val storyIndexChats = remember(foldersState.chatsByFolder, storyChatIds) { + buildStoryChatIndex( + chatsByFolder = foldersState.chatsByFolder, + storyChatIds = storyChatIds + ) + } val mainStoryStripItems = remember(storyIndexChats, storiesState.mainActiveStories) { storiesState.mainActiveStories.mapNotNull { storyList -> val chat = storyIndexChats[storyList.chatId] ?: return@mapNotNull null @@ -3142,6 +3147,28 @@ internal fun shouldShowCreateStoryStripButton( return selectedFolderId != -2 && hasVisibleStories } +internal fun buildStoryChatIndex( + chatsByFolder: Map>, + storyChatIds: Set +): Map { + if (storyChatIds.isEmpty()) return emptyMap() + + val remainingIds = storyChatIds.toMutableSet() + val index = LinkedHashMap(storyChatIds.size) + chatsByFolder.values.forEach { chats -> + chats.forEach { chat -> + if (chat.id in remainingIds) { + index.putIfAbsent(chat.id, chat) + remainingIds.remove(chat.id) + } + } + if (remainingIds.isEmpty()) { + return index + } + } + return index +} + private enum class StoriesHeaderMode { Hidden, Skeleton, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt index 26e64790e..25c0762ef 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt @@ -137,7 +137,8 @@ class DefaultChatListComponent( private var hasSeenResume = false private val prefetchSemaphore = Semaphore(PREFETCH_CONCURRENCY) private val messagePrefetchTimestamps = mutableMapOf() - private val storyPrefetchChatIds = mutableSetOf() + private val storyPrefetchTimestamps = mutableMapOf() + private var trackedStoryChatIds = emptySet() private val lastPinnedFolderLogByFolder = mutableMapOf() private fun ChatListComponent.State.toUiState() = ChatListComponent.UiState( @@ -308,10 +309,6 @@ class DefaultChatListComponent( chatFolderRepository.folderChatsFlow .onEach { update -> val normalizedList = normalizeFolderChats(update.chats) - val trackedStoryChatIds = storyRepository.activeStories.value.values - .asSequence() - .flatMap(List::asSequence) - .mapTo(mutableSetOf()) { it.chatId } val shouldRefreshStories = shouldRefreshStoriesForFolderUpdate( previousChats = _state.value.chatsByFolder[update.folderId], updatedChats = normalizedList, @@ -452,6 +449,9 @@ class DefaultChatListComponent( storyRepository.activeStories .onEach { activeStories -> + trackedStoryChatIds = activeStories.values.asSequence() + .flatMap(List::asSequence) + .mapTo(linkedSetOf()) { it.chatId } Log.d( STORY_TAG, "story repository update main=${activeStories[StoryListType.MAIN].orEmpty().size} " + @@ -486,6 +486,8 @@ class DefaultChatListComponent( else -> { hasRequestedStoryLists = false + trackedStoryChatIds = emptySet() + storyPrefetchTimestamps.clear() } } } @@ -1291,20 +1293,28 @@ class DefaultChatListComponent( } private fun prefetchStoryListsForChats(folderId: Int, chats: List) { + val now = System.currentTimeMillis() + storyPrefetchTimestamps.entries.removeAll { (_, timestamp) -> + now - timestamp >= STORY_PREFETCH_TTL_MS + } val hintedChats = chats.asSequence() .filter { it.activeStoryId != 0 || !it.activeStoryStateType.isNullOrBlank() } .filter { chat -> - storyRepository.activeStories.value.values - .asSequence() - .flatMap(List::asSequence) - .none { active -> active.chatId == chat.id } + chat.id !in trackedStoryChatIds + } + .filter { chat -> + val lastPrefetchAt = storyPrefetchTimestamps[chat.id] ?: return@filter true + now - lastPrefetchAt >= STORY_PREFETCH_TTL_MS } - .filter { chat -> storyPrefetchChatIds.add(chat.id) } .take(STORY_PREFETCH_MAX) .toList() if (hintedChats.isEmpty()) return + hintedChats.forEach { chat -> + storyPrefetchTimestamps[chat.id] = now + } + scope.launch(Dispatchers.IO) { hintedChats.forEach { chat -> Log.d( @@ -1439,6 +1449,7 @@ class DefaultChatListComponent( private const val PREFETCH_PAGE_SIZE = 30 private const val PREFETCH_TTL_MS = 60_000L private const val STORY_PREFETCH_MAX = 12 + private const val STORY_PREFETCH_TTL_MS = 5 * 60_000L } private fun toggleSelection(id: Long) { From cd481f87c069f95191fe299dad14ef495d1e4ac0 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:18:15 +0300 Subject: [PATCH 16/16] stories: Implement exceptions/filters --- .../stories/DefaultStoriesHostComponent.kt | 253 ++++++++++++-- .../features/stories/StoriesHostComponent.kt | 26 +- .../features/stories/StoriesHostContent.kt | 13 + .../components/StoriesStripComponents.kt | 2 +- .../StoryComposerOverlayComponents.kt | 148 +++++++-- .../StoryComposerSettingsComponents.kt | 313 +++++++++++++++++- .../components/StoryViewerComponents.kt | 35 +- .../components/StoryViewerSceneComponents.kt | 39 ++- .../presentation/root/DefaultRootComponent.kt | 33 +- .../src/main/res/values-ru-rRU/string.xml | 33 ++ presentation/src/main/res/values/string.xml | 27 ++ 11 files changed, 851 insertions(+), 71 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt index fa0b1e776..8127a2c1b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/DefaultStoriesHostComponent.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import org.monogram.domain.models.UserModel import org.monogram.domain.models.stories.ActiveStoryListModel import org.monogram.domain.models.stories.StoryComposerDraftModel import org.monogram.domain.models.stories.StoryComposerMediaItemModel @@ -48,6 +49,8 @@ class DefaultStoriesHostComponent( private var hasLoadedActiveStories = false private var storyLoadJob: Job? = null private var storyRefreshJob: Job? = null + private var audienceLoadJob: Job? = null + private var audienceSearchJob: Job? = null private var storyMediaLoadingMessageJob: Job? = null private var storyMediaLoadingMessageKey: Pair? = null private val chatPresentationCache = mutableMapOf() @@ -123,6 +126,7 @@ class DefaultStoriesHostComponent( canManageStories = false, composerMode = StoryComposerMode.CREATE, editingStoryId = null, + audiencePicker = StoryAudiencePickerState(), inlineError = null, isSubmitting = false, isStoryStatisticsVisible = false, @@ -207,6 +211,7 @@ class DefaultStoriesHostComponent( canManageStories = false, composerMode = StoryComposerMode.CREATE, editingStoryId = null, + audiencePicker = StoryAudiencePickerState(), inlineError = null, isSubmitting = false, isStoryStatisticsVisible = false, @@ -308,6 +313,7 @@ class DefaultStoriesHostComponent( canManageStories = false, composerMode = StoryComposerMode.CREATE, editingStoryId = null, + audiencePicker = StoryAudiencePickerState(), inlineError = null, isSubmitting = false, isStoryStatisticsVisible = false, @@ -423,6 +429,7 @@ class DefaultStoriesHostComponent( composerMode = composerMode, editingStoryId = editingStoryId, composerDraft = draft, + audiencePicker = StoryAudiencePickerState(), postCapability = null, inlineError = null, isSubmitting = false, @@ -445,22 +452,22 @@ class DefaultStoriesHostComponent( if (composerMode == StoryComposerMode.CREATE) storyRepository.canPostStory(chatId) else null Log.d(TAG, "openComposer chatId=$chatId mode=$composerMode capability=$capability") - _state.value = _state.value.copy( - mode = StoriesHostComponent.Mode.Composer, + val latest = _state.value + if ( + latest.mode != StoriesHostComponent.Mode.Composer || + latest.chatId != chatId || + latest.composerMode != composerMode || + latest.editingStoryId != editingStoryId + ) { + return@launch + } + + _state.value = latest.copy( isLoading = false, - chatId = chatId, chatTitle = resolveChatTitle(chatId), chatAvatarPath = resolveChatAvatar(chatId), canManageStories = canManageStories(chatId), - composerMode = composerMode, - editingStoryId = editingStoryId, - composerDraft = draft, postCapability = capability, - inlineError = null, - showMediaPicker = !draft.isValid, - showCamera = false, - isSubmitting = false, - showInlineVideo = false, showStoryMediaLoadingMessage = false ) } @@ -469,6 +476,8 @@ class DefaultStoriesHostComponent( override fun dismiss() { storyLoadJob?.cancel() storyRefreshJob?.cancel() + audienceLoadJob?.cancel() + audienceSearchJob?.cancel() syncStoryMediaLoadingMessage(disableOnly = true) val current = _state.value if ( @@ -558,16 +567,132 @@ class DefaultStoriesHostComponent( } override fun updatePrivacy(mode: StoryPrivacyUi) { - _state.value = _state.value.copy( - composerDraft = _state.value.composerDraft.copy( - privacy = StoryPrivacySettingsModel( - mode = when (mode) { - StoryPrivacyUi.EVERYONE -> StoryPrivacyMode.EVERYONE - StoryPrivacyUi.CONTACTS -> StoryPrivacyMode.CONTACTS - StoryPrivacyUi.CLOSE_FRIENDS -> StoryPrivacyMode.CLOSE_FRIENDS - } + val updatedDraft = _state.value.composerDraft.copy( + privacy = updateStoryPrivacyMode(_state.value.composerDraft.privacy, mode) + ) + _state.value = _state.value.copy(composerDraft = updatedDraft) + if ( + mode == StoryPrivacyUi.SELECTED_USERS && + updatedDraft.privacy.selectedUserIds.isEmpty() + ) { + showAudiencePicker(StoryAudienceFilterMode.SHOW_TO) + } + } + + override fun showAudiencePicker(filterMode: StoryAudienceFilterMode) { + audienceLoadJob?.cancel() + audienceSearchJob?.cancel() + + val current = _state.value + val selectedIds = resolveAudienceSelectionIds(current.composerDraft.privacy, filterMode) + _state.value = current.copy( + audiencePicker = current.audiencePicker.copy( + isVisible = true, + filterMode = filterMode, + searchQuery = "", + searchResults = emptyList(), + isLoading = true, + isSearching = false + ) + ) + + audienceLoadJob = scope.launch { + val contacts = userRepository.getContacts() + val selectedUsers = resolveAudienceUsers(selectedIds, contacts) + val mergedContacts = mergeAudienceUsers(contacts, selectedUsers) + val latest = _state.value + if ( + latest.mode != StoriesHostComponent.Mode.Composer || + !latest.audiencePicker.isVisible || + latest.audiencePicker.filterMode != filterMode + ) { + return@launch + } + _state.value = latest.copy( + audiencePicker = latest.audiencePicker.copy( + contacts = mergedContacts, + selectedUsers = selectedUsers, + isLoading = false + ) + ) + } + } + + override fun dismissAudiencePicker() { + audienceSearchJob?.cancel() + val current = _state.value + _state.value = current.copy( + audiencePicker = current.audiencePicker.copy( + isVisible = false, + searchQuery = "", + searchResults = emptyList(), + isSearching = false + ) + ) + } + + override fun updateAudienceSearchQuery(query: String) { + audienceSearchJob?.cancel() + val current = _state.value + _state.value = current.copy( + audiencePicker = current.audiencePicker.copy( + searchQuery = query, + searchResults = if (query.isBlank()) emptyList() else current.audiencePicker.searchResults, + isSearching = query.isNotBlank() + ) + ) + + if (query.isBlank()) { + return + } + + audienceSearchJob = scope.launch { + val results = userRepository.searchContacts(query) + val latest = _state.value + if (latest.audiencePicker.searchQuery != query) { + return@launch + } + _state.value = latest.copy( + audiencePicker = latest.audiencePicker.copy( + searchResults = mergeAudienceUsers( + results, + latest.audiencePicker.selectedUsers + ), + isSearching = false ) ) + } + } + + override fun toggleAudienceUserSelection(userId: Long) { + val current = _state.value + val updatedPrivacy = toggleStoryAudienceUser( + current.composerDraft.privacy, + userId, + current.audiencePicker.filterMode + ) + val selectedIds = + resolveAudienceSelectionIds(updatedPrivacy, current.audiencePicker.filterMode) + val selectedUsers = selectedIds.mapNotNull { selectedId -> + current.audiencePicker.selectedUsers.find { it.id == selectedId } + ?: current.audiencePicker.contacts.find { it.id == selectedId } + ?: current.audiencePicker.searchResults.find { it.id == selectedId } + } + _state.value = current.copy( + composerDraft = current.composerDraft.copy(privacy = updatedPrivacy), + audiencePicker = current.audiencePicker.copy(selectedUsers = selectedUsers) + ) + } + + override fun clearAudienceSelection() { + val current = _state.value + val clearedPrivacy = clearStoryAudienceSelection( + current.composerDraft.privacy, + current.audiencePicker.filterMode + ) + _state.value = current.copy( + composerDraft = current.composerDraft.copy(privacy = clearedPrivacy), + audiencePicker = current.audiencePicker.copy(selectedUsers = emptyList()) ) } @@ -1206,9 +1331,11 @@ class DefaultStoriesHostComponent( } private suspend fun canManageStories(chatId: Long): Boolean { - val me = userRepository.getMe() + val currentUserId = _state.value.currentUserId + ?: userRepository.currentUserFlow.value?.id + ?: userRepository.getMe().id val chat = chatListRepository.getChatById(chatId) - return me?.id == chatId || chat?.isAdmin == true + return currentUserId == chatId || chat?.isAdmin == true } private suspend fun resolvePublicStoryUsername(chatId: Long): String? { @@ -1278,6 +1405,24 @@ class DefaultStoriesHostComponent( } } + private suspend fun resolveAudienceUsers( + userIds: List, + contacts: List + ): List { + val contactsById = contacts.associateBy(UserModel::id) + return userIds.mapNotNull { userId -> + contactsById[userId] ?: userRepository.getUser(userId) + } + } + + private fun mergeAudienceUsers( + primary: List, + secondary: List + ): List { + return (secondary + primary) + .distinctBy(UserModel::id) + } + private fun createDefaultState(): StoriesHostComponent.State { return StoriesHostComponent.State( currentUserId = userRepository.currentUserFlow.value?.id, @@ -1360,6 +1505,7 @@ private fun restoreViewerState(state: StoriesHostComponent.State): StoriesHostCo isLoading = false, composerMode = StoryComposerMode.CREATE, editingStoryId = null, + audiencePicker = StoryAudiencePickerState(), postCapability = null, inlineError = null, isSubmitting = false, @@ -1459,6 +1605,56 @@ internal suspend fun saveStoryDraft( } } +internal fun updateStoryPrivacyMode( + current: StoryPrivacySettingsModel, + mode: StoryPrivacyUi +): StoryPrivacySettingsModel { + return current.copy( + mode = when (mode) { + StoryPrivacyUi.EVERYONE -> StoryPrivacyMode.EVERYONE + StoryPrivacyUi.CONTACTS -> StoryPrivacyMode.CONTACTS + StoryPrivacyUi.CLOSE_FRIENDS -> StoryPrivacyMode.CLOSE_FRIENDS + StoryPrivacyUi.SELECTED_USERS -> StoryPrivacyMode.SELECTED_USERS + } + ) +} + +internal fun toggleStoryAudienceUser( + current: StoryPrivacySettingsModel, + userId: Long, + filterMode: StoryAudienceFilterMode +): StoryPrivacySettingsModel { + return when (filterMode) { + StoryAudienceFilterMode.SHOW_TO -> current.copy( + selectedUserIds = current.selectedUserIds.toggleUserId(userId) + ) + + StoryAudienceFilterMode.HIDE_FROM -> current.copy( + exceptUserIds = current.exceptUserIds.toggleUserId(userId) + ) + } +} + +internal fun clearStoryAudienceSelection( + current: StoryPrivacySettingsModel, + filterMode: StoryAudienceFilterMode +): StoryPrivacySettingsModel { + return when (filterMode) { + StoryAudienceFilterMode.SHOW_TO -> current.copy(selectedUserIds = emptyList()) + StoryAudienceFilterMode.HIDE_FROM -> current.copy(exceptUserIds = emptyList()) + } +} + +internal fun resolveAudienceSelectionIds( + privacy: StoryPrivacySettingsModel, + filterMode: StoryAudienceFilterMode +): List { + return when (filterMode) { + StoryAudienceFilterMode.SHOW_TO -> privacy.selectedUserIds + StoryAudienceFilterMode.HIDE_FROM -> privacy.exceptUserIds + } +} + internal fun resolveStorySaveValidationError( stringProvider: StringProvider, draft: StoryComposerDraftModel, @@ -1474,6 +1670,13 @@ internal fun resolveStorySaveValidationError( return stringProvider.getString("story_validation_caption_too_long", captionLengthMax) } + if ( + draft.privacy.mode == StoryPrivacyMode.SELECTED_USERS && + draft.privacy.selectedUserIds.isEmpty() + ) { + return stringProvider.getString("story_validation_selected_users_required") + } + if (!draft.widgetLink.isNullOrBlank() && (!isPremiumUser || storyOptions.linkAreaCountMax <= 0)) { return stringProvider.getString("story_validation_links_premium") } @@ -1552,6 +1755,14 @@ private fun StoryComposerDraftModel.forSingleMedia( ) } +private fun List.toggleUserId(userId: Long): List { + return if (contains(userId)) { + filterNot { it == userId } + } else { + this + userId + } +} + internal fun buildViewerItems(activeStories: List): List { return activeStories.flatMap { activeStoriesForChat -> activeStoriesForChat.stories.map { storySummary -> diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt index d23819d9e..cb69b714f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostComponent.kt @@ -1,6 +1,7 @@ package org.monogram.presentation.features.stories import kotlinx.coroutines.flow.StateFlow +import org.monogram.domain.models.UserModel import org.monogram.domain.models.stories.ActiveStoryListModel import org.monogram.domain.models.stories.StoryAvailableReactionsModel import org.monogram.domain.models.stories.StoryComposerDraftModel @@ -47,6 +48,11 @@ interface StoriesHostComponent { fun selectComposerMedia(index: Int) fun updateCaption(caption: String) fun updatePrivacy(mode: StoryPrivacyUi) + fun showAudiencePicker(filterMode: StoryAudienceFilterMode) + fun dismissAudiencePicker() + fun updateAudienceSearchQuery(query: String) + fun toggleAudienceUserSelection(userId: Long) + fun clearAudienceSelection() fun updateActivePeriod(seconds: Int) fun updateProtectContent(protectContent: Boolean) fun updateKeepOnProfile(keepOnProfile: Boolean) @@ -92,6 +98,7 @@ interface StoriesHostComponent { val composerMode: StoryComposerMode = StoryComposerMode.CREATE, val editingStoryId: Int? = null, val composerDraft: StoryComposerDraftModel = StoryComposerDraftModel(), + val audiencePicker: StoryAudiencePickerState = StoryAudiencePickerState(), val postCapability: StoryPostCapabilityModel? = null, val inlineError: String? = null, val isSubmitting: Boolean = false, @@ -148,9 +155,26 @@ data class StoryViewerUiModel( enum class StoryPrivacyUi { EVERYONE, CONTACTS, - CLOSE_FRIENDS + CLOSE_FRIENDS, + SELECTED_USERS } +enum class StoryAudienceFilterMode { + SHOW_TO, + HIDE_FROM +} + +data class StoryAudiencePickerState( + val isVisible: Boolean = false, + val filterMode: StoryAudienceFilterMode = StoryAudienceFilterMode.HIDE_FROM, + val contacts: List = emptyList(), + val selectedUsers: List = emptyList(), + val searchQuery: String = "", + val searchResults: List = emptyList(), + val isLoading: Boolean = false, + val isSearching: Boolean = false +) + data class StoryStripItemUiModel( val chatId: Long, val title: String, diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt index f89094dbb..2192fe340 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/StoriesHostContent.kt @@ -72,6 +72,19 @@ import org.monogram.domain.models.stories.StoryPostCapabilityModel import org.monogram.domain.models.stories.StoryReactionModel import org.monogram.domain.models.stories.StoryStealthModeModel import org.monogram.presentation.R +import org.monogram.presentation.features.stories.components.AddStoryStripTileComponent +import org.monogram.presentation.features.stories.components.StoryCapabilityCardComponent +import org.monogram.presentation.features.stories.components.StoryComposerOverlayComponent +import org.monogram.presentation.features.stories.components.StoryDurationSectionComponent +import org.monogram.presentation.features.stories.components.StoryInteractionsSheetComponent +import org.monogram.presentation.features.stories.components.StoryLinksSheetComponent +import org.monogram.presentation.features.stories.components.StoryPrivacySectionComponent +import org.monogram.presentation.features.stories.components.StoryReactionPickerSheetComponent +import org.monogram.presentation.features.stories.components.StorySettingsCardComponent +import org.monogram.presentation.features.stories.components.StoryStripTileComponent +import org.monogram.presentation.features.stories.components.StorySwitchRowComponent +import org.monogram.presentation.features.stories.components.StoryViewerChromeComponent +import org.monogram.presentation.features.stories.components.StoryViewerScaffoldComponent import org.monogram.presentation.features.viewers.VideoViewer import java.util.Date import kotlin.math.absoluteValue diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoriesStripComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoriesStripComponents.kt index 38bef0c3c..89dfd29ac 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoriesStripComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoriesStripComponents.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.stories +package org.monogram.presentation.features.stories.components import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryComposerOverlayComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryComposerOverlayComponents.kt index c701adec0..f43a7ca93 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryComposerOverlayComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryComposerOverlayComponents.kt @@ -1,15 +1,19 @@ -package org.monogram.presentation.features.stories +package org.monogram.presentation.features.stories.components import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith @@ -71,6 +75,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -83,8 +88,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -97,6 +104,7 @@ import coil3.compose.AsyncImage import org.monogram.domain.models.stories.StoryComposerMediaItemModel import org.monogram.domain.models.stories.StoryMediaType import org.monogram.domain.models.stories.StoryPrivacyMode +import org.monogram.domain.models.stories.StoryPrivacySettingsModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SettingsSwitchTile @@ -109,6 +117,17 @@ import org.monogram.presentation.features.chats.conversation.ui.inputbar.copyUri import org.monogram.presentation.features.chats.conversation.ui.inputbar.declaredPermissions import org.monogram.presentation.features.chats.conversation.ui.inputbar.hasAllPermissions import org.monogram.presentation.features.gallery.GalleryScreen +import org.monogram.presentation.features.stories.STORY_MEDIA_ASPECT_RATIO +import org.monogram.presentation.features.stories.StoriesHostComponent +import org.monogram.presentation.features.stories.StoryAudienceFilterMode +import org.monogram.presentation.features.stories.StoryCapabilityPresentation +import org.monogram.presentation.features.stories.StoryComposerMode +import org.monogram.presentation.features.stories.StoryErrorBanner +import org.monogram.presentation.features.stories.StoryPrivacyUi +import org.monogram.presentation.features.stories.canPublishStory +import org.monogram.presentation.features.stories.formatStoryDurationLabel +import org.monogram.presentation.features.stories.inferUriMediaType +import org.monogram.presentation.features.stories.toCapabilityPresentation import java.io.File private enum class StoryComposerStage { @@ -119,7 +138,7 @@ private enum class StoryComposerStage { } private data class StoryComposerActionModel( - val icon: androidx.compose.ui.graphics.vector.ImageVector, + val icon: ImageVector, val title: Int, val subtitle: Int, val iconColor: Color, @@ -196,13 +215,13 @@ internal fun StoryComposerOverlayComponent( val galleryPermissions = remember { when { - android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> listOf( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> listOf( Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED ) - android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU -> listOf( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> listOf( Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO ) @@ -211,7 +230,7 @@ internal fun StoryComposerOverlayComponent( } } val fullGalleryPermissions = remember { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { listOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO) } else { listOf(Manifest.permission.READ_EXTERNAL_STORAGE) @@ -227,7 +246,7 @@ internal fun StoryComposerOverlayComponent( } fun hasPartialGalleryPermission(): Boolean { - return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE && + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && ContextCompat.checkSelfPermission( context, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED @@ -469,15 +488,41 @@ internal fun StoryComposerOverlayComponent( title = stringResource(R.string.story_privacy_label), subtitle = null ) - StoryChoiceSurface { - StoryPrivacySectionComponent( - selected = when (state.composerDraft.privacy.mode) { - StoryPrivacyMode.CONTACTS -> StoryPrivacyUi.CONTACTS - StoryPrivacyMode.CLOSE_FRIENDS -> StoryPrivacyUi.CLOSE_FRIENDS - else -> StoryPrivacyUi.EVERYONE - }, - onSelect = component::updatePrivacy - ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + StoryChoiceSurface { + StoryPrivacySectionComponent( + selected = when (state.composerDraft.privacy.mode) { + StoryPrivacyMode.CONTACTS -> StoryPrivacyUi.CONTACTS + StoryPrivacyMode.CLOSE_FRIENDS -> StoryPrivacyUi.CLOSE_FRIENDS + StoryPrivacyMode.SELECTED_USERS -> StoryPrivacyUi.SELECTED_USERS + else -> StoryPrivacyUi.EVERYONE + }, + onSelect = component::updatePrivacy + ) + } + AnimatedVisibility( + visible = state.composerDraft.privacy.mode != StoryPrivacyMode.CLOSE_FRIENDS, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + StoryAudienceFilterRowComponent( + privacy = state.composerDraft.privacy, + onClick = { + component.showAudiencePicker( + when (state.composerDraft.privacy.mode) { + StoryPrivacyMode.SELECTED_USERS -> { + StoryAudienceFilterMode.SHOW_TO + } + + else -> StoryAudienceFilterMode.HIDE_FROM + } + ) + } + ) + } } StorySectionHeader( @@ -558,10 +603,31 @@ internal fun StoryComposerOverlayComponent( } } + if (state.audiencePicker.isVisible) { + ModalBottomSheet( + onDismissRequest = component::dismissAudiencePicker, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), + dragHandle = { BottomSheetDefaults.DragHandle() }, + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) + ) { + StoryAudiencePickerContentComponent( + state = state.audiencePicker, + onDismiss = component::dismissAudiencePicker, + onSearchQueryChange = component::updateAudienceSearchQuery, + onToggleUserSelection = component::toggleAudienceUserSelection, + onClearSelection = component::clearAudienceSelection + ) + } + } + if (state.showMediaPicker) { ModalBottomSheet( onDismissRequest = component::dismissMediaPicker, - sheetState = androidx.compose.material3.rememberModalBottomSheetState( + sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true ), dragHandle = { BottomSheetDefaults.DragHandle() }, @@ -900,6 +966,7 @@ private fun StoryPreviewDetailsPanel( icon = when (state.composerDraft.privacy.mode) { StoryPrivacyMode.CONTACTS -> Icons.Rounded.PeopleAlt StoryPrivacyMode.CLOSE_FRIENDS -> Icons.Rounded.Favorite + StoryPrivacyMode.SELECTED_USERS -> Icons.Rounded.Person else -> Icons.Rounded.Public }, title = R.string.story_privacy_label, @@ -946,11 +1013,7 @@ private fun StoryPreviewDetailsPanel( Column { rows.forEachIndexed { index, item -> val subtitleText = when (index) { - 1 -> when (state.composerDraft.privacy.mode) { - StoryPrivacyMode.CONTACTS -> stringResource(R.string.story_privacy_contacts) - StoryPrivacyMode.CLOSE_FRIENDS -> stringResource(R.string.story_privacy_close_friends) - else -> stringResource(R.string.story_privacy_everyone) - } + 1 -> formatStoryPrivacySummary(state.composerDraft.privacy) 2 -> formatStoryDurationLabel(state.composerDraft.activePeriodSeconds) 3 -> state.composerDraft.widgetLink.orEmpty() @@ -996,6 +1059,45 @@ private fun StoryPreviewDetailsPanel( } } +@Composable +private fun formatStoryPrivacySummary(privacy: StoryPrivacySettingsModel): String { + val baseLabel = when (privacy.mode) { + StoryPrivacyMode.EVERYONE -> stringResource(R.string.story_privacy_everyone) + StoryPrivacyMode.CONTACTS -> stringResource(R.string.story_privacy_contacts) + StoryPrivacyMode.CLOSE_FRIENDS -> stringResource(R.string.story_privacy_close_friends) + StoryPrivacyMode.SELECTED_USERS -> stringResource(R.string.story_privacy_selected_users) + } + val filterSummary = when { + privacy.mode == StoryPrivacyMode.SELECTED_USERS && privacy.selectedUserIds.isEmpty() -> { + stringResource(R.string.story_privacy_show_to_empty) + } + + privacy.mode == StoryPrivacyMode.SELECTED_USERS -> { + pluralStringResource( + R.plurals.story_privacy_show_to_count, + privacy.selectedUserIds.size, + privacy.selectedUserIds.size + ) + } + + privacy.exceptUserIds.isNotEmpty() -> { + pluralStringResource( + R.plurals.story_privacy_hide_from_count, + privacy.exceptUserIds.size, + privacy.exceptUserIds.size + ) + } + + else -> null + } + + return if (filterSummary.isNullOrBlank()) { + baseLabel + } else { + "$baseLabel, $filterSummary" + } +} + @Composable private fun StoryComposerPreviewCard( state: StoriesHostComponent.State, @@ -1344,7 +1446,7 @@ private fun StoryComposerMediaPage( ) } - androidx.compose.animation.AnimatedVisibility( + AnimatedVisibility( visible = isVideoBuffering, modifier = Modifier.align(Alignment.Center) ) { @@ -1505,7 +1607,7 @@ private fun StoryComposerPreviewPager( ) } - androidx.compose.animation.AnimatedVisibility( + AnimatedVisibility( visible = isVideoBuffering && page == pagerState.currentPage, modifier = Modifier.align(Alignment.Center) ) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryComposerSettingsComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryComposerSettingsComponents.kt index b82335203..900d82e5c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryComposerSettingsComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryComposerSettingsComponents.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.stories +package org.monogram.presentation.features.stories.components import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable @@ -7,27 +7,56 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.PeopleAlt +import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Public import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Shield +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable 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.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import org.monogram.domain.models.UserModel +import org.monogram.domain.models.stories.StoryPrivacyMode +import org.monogram.domain.models.stories.StoryPrivacySettingsModel import org.monogram.presentation.R +import org.monogram.presentation.core.ui.Avatar +import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.core.ui.SettingsGroup +import org.monogram.presentation.core.ui.SettingsTextField +import org.monogram.presentation.core.ui.SettingsTile +import org.monogram.presentation.features.stories.StoryAudienceFilterMode +import org.monogram.presentation.features.stories.StoryAudiencePickerState +import org.monogram.presentation.features.stories.StoryCapabilityPresentation +import org.monogram.presentation.features.stories.StoryPrivacyUi @Composable internal fun StorySettingsCardComponent( @@ -118,17 +147,297 @@ internal fun StoryPrivacySectionComponent( StoryPrivacyUi.EVERYONE -> stringResource(R.string.story_privacy_everyone) StoryPrivacyUi.CONTACTS -> stringResource(R.string.story_privacy_contacts) StoryPrivacyUi.CLOSE_FRIENDS -> stringResource(R.string.story_privacy_close_friends) + StoryPrivacyUi.SELECTED_USERS -> stringResource(R.string.story_privacy_selected_users) }, icon = when (option) { StoryPrivacyUi.EVERYONE -> Icons.Rounded.Public StoryPrivacyUi.CONTACTS -> Icons.Rounded.PeopleAlt StoryPrivacyUi.CLOSE_FRIENDS -> Icons.Rounded.Favorite + StoryPrivacyUi.SELECTED_USERS -> Icons.Rounded.Person } ) } } } +@Composable +internal fun StoryAudienceFilterRowComponent( + privacy: StoryPrivacySettingsModel, + onClick: () -> Unit +) { + val filterMode = when (privacy.mode) { + StoryPrivacyMode.SELECTED_USERS -> StoryAudienceFilterMode.SHOW_TO + StoryPrivacyMode.CLOSE_FRIENDS -> null + StoryPrivacyMode.EVERYONE, + StoryPrivacyMode.CONTACTS -> StoryAudienceFilterMode.HIDE_FROM + } ?: return + + val selectedCount = when (filterMode) { + StoryAudienceFilterMode.SHOW_TO -> privacy.selectedUserIds.size + StoryAudienceFilterMode.HIDE_FROM -> privacy.exceptUserIds.size + } + val title = stringResource( + if (filterMode == StoryAudienceFilterMode.SHOW_TO) { + R.string.story_privacy_show_to + } else { + R.string.story_privacy_hide_from + } + ) + val subtitle = when (filterMode) { + StoryAudienceFilterMode.SHOW_TO if selectedCount == 0 -> { + stringResource(R.string.story_privacy_show_to_empty) + } + + StoryAudienceFilterMode.HIDE_FROM if selectedCount == 0 -> { + stringResource(R.string.story_privacy_hide_from_empty) + } + + StoryAudienceFilterMode.SHOW_TO -> { + pluralStringResource( + R.plurals.story_privacy_show_to_count, + selectedCount, + selectedCount + ) + } + + else -> { + pluralStringResource( + R.plurals.story_privacy_hide_from_count, + selectedCount, + selectedCount + ) + } + } + + SettingsTile( + icon = if (filterMode == StoryAudienceFilterMode.SHOW_TO) { + Icons.Rounded.PeopleAlt + } else { + Icons.Rounded.Shield + }, + title = title, + subtitle = subtitle, + iconColor = MaterialTheme.colorScheme.secondary, + position = ItemPosition.STANDALONE, + onClick = onClick + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun StoryAudiencePickerContentComponent( + state: StoryAudiencePickerState, + onDismiss: () -> Unit, + onSearchQueryChange: (String) -> Unit, + onToggleUserSelection: (Long) -> Unit, + onClearSelection: () -> Unit +) { + val users = if (state.searchQuery.isNotBlank()) state.searchResults else state.contacts + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource( + if (state.filterMode == StoryAudienceFilterMode.SHOW_TO) { + R.string.story_audience_picker_show_to_title + } else { + R.string.story_audience_picker_hide_from_title + } + ), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource( + if (state.filterMode == StoryAudienceFilterMode.SHOW_TO) { + R.string.story_audience_picker_show_to_subtitle + } else { + R.string.story_audience_picker_hide_from_subtitle + } + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.cancel_button)) + } + } + + SettingsTextField( + value = state.searchQuery, + onValueChange = onSearchQueryChange, + placeholder = stringResource(R.string.story_audience_picker_search), + icon = Icons.Rounded.Search, + position = ItemPosition.STANDALONE, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + if (state.selectedUsers.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = pluralStringResource( + R.plurals.story_audience_selected_count, + state.selectedUsers.size, + state.selectedUsers.size + ), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface + ) + TextButton(onClick = onClearSelection) { + Text(text = stringResource(R.string.story_privacy_clear_selection)) + } + } + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + state.selectedUsers.forEach { user -> + FilterChip( + selected = true, + onClick = { onToggleUserSelection(user.id) }, + label = { + Text(text = user.displayTitle()) + }, + trailingIcon = { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = null, + modifier = Modifier.padding(end = 2.dp) + ) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer + ), + border = null + ) + } + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 220.dp, max = 420.dp), + contentAlignment = Alignment.Center + ) { + when { + state.isLoading -> { + CircularProgressIndicator() + } + + state.isSearching -> { + CircularProgressIndicator() + } + + users.isEmpty() -> { + Text( + text = stringResource( + if (state.searchQuery.isNotBlank()) { + R.string.story_audience_picker_no_results + } else { + R.string.story_audience_picker_empty + } + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + else -> { + SettingsGroup { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(vertical = 4.dp) + ) { + items(users, key = UserModel::id) { user -> + StoryAudienceUserRowComponent( + user = user, + isSelected = state.selectedUsers.any { it.id == user.id }, + onClick = { onToggleUserSelection(user.id) } + ) + } + } + } + } + } + } + } +} + +@Composable +private fun StoryAudienceUserRowComponent( + user: UserModel, + isSelected: Boolean, + onClick: () -> Unit +) { + ListItem( + headlineContent = { + Text( + text = user.displayTitle(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + }, + supportingContent = { + user.username + ?.takeIf { it.isNotBlank() } + ?.let { username -> + Text( + text = "@$username", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + leadingContent = { + Avatar( + path = user.avatarPath, + fallbackPath = user.personalAvatarPath, + name = user.firstName, + size = 40.dp + ) + }, + trailingContent = { + Checkbox( + checked = isSelected, + onCheckedChange = { onClick() } + ) + }, + modifier = Modifier.clickable(onClick = onClick), + colors = ListItemDefaults.colors( + containerColor = Color.Transparent + ) + ) +} + +private fun UserModel.displayTitle(): String { + val fullName = listOfNotNull(firstName, lastName) + .joinToString(" ") + .trim() + return fullName.ifBlank { + username?.takeIf { it.isNotBlank() }?.let { "@$it" } ?: id.toString() + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable internal fun StoryDurationSectionComponent( @@ -160,7 +469,7 @@ internal fun StoryDurationSectionComponent( @Composable private fun StoryCompactChoiceButton( label: String, - icon: androidx.compose.ui.graphics.vector.ImageVector, + icon: ImageVector, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt index 0da4c508d..8c921abed 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerComponents.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.stories +package org.monogram.presentation.features.stories.components import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -90,8 +90,10 @@ import org.monogram.domain.models.stories.StoryInteractionActorType import org.monogram.domain.models.stories.StoryInteractionPageModel import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryOptionsModel import org.monogram.domain.models.stories.StoryReactionModel import org.monogram.domain.models.stories.StoryReactionUnavailabilityReasonModel +import org.monogram.domain.models.stories.StoryStealthModeModel import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.R import org.monogram.presentation.features.stickers.ui.menu.ActionMenuPopup @@ -99,6 +101,33 @@ import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow import org.monogram.presentation.features.stickers.ui.menu.MenuToggleRow import org.monogram.presentation.features.stickers.ui.menu.StickerEmojiMenu import org.monogram.presentation.features.stickers.ui.view.StickerImage +import org.monogram.presentation.features.stories.StoriesHostComponent +import org.monogram.presentation.features.stories.StoryErrorBanner +import org.monogram.presentation.features.stories.StoryHeaderInfoState +import org.monogram.presentation.features.stories.StoryHeaderSkeleton +import org.monogram.presentation.features.stories.StoryInteractionAvatar +import org.monogram.presentation.features.stories.StoryMetadataChip +import org.monogram.presentation.features.stories.StorySkeletonPlaceholder +import org.monogram.presentation.features.stories.StoryStealthAvailability +import org.monogram.presentation.features.stories.StoryTopIconButton +import org.monogram.presentation.features.stories.StoryViewerBorderColor +import org.monogram.presentation.features.stories.StoryViewerContentColor +import org.monogram.presentation.features.stories.StoryViewerMenuAction +import org.monogram.presentation.features.stories.StoryViewerMutedContentColor +import org.monogram.presentation.features.stories.StoryViewerSheetColor +import org.monogram.presentation.features.stories.StoryViewerSheetSurfaceColor +import org.monogram.presentation.features.stories.StoryViewerSheetSurfaceVariantColor +import org.monogram.presentation.features.stories.StoryViewerSource +import org.monogram.presentation.features.stories.buildStoryPositionText +import org.monogram.presentation.features.stories.buildStoryReactionSections +import org.monogram.presentation.features.stories.buildStoryViewerMenuActions +import org.monogram.presentation.features.stories.formatStoryPostedTime +import org.monogram.presentation.features.stories.resolveStoryStealthAvailability +import org.monogram.presentation.features.stories.storyInteractionTypeLabel +import org.monogram.presentation.features.stories.storyReactionLabel +import org.monogram.presentation.features.stories.storyViewerMenuIcon +import org.monogram.presentation.features.stories.storyViewerMenuTitle +import org.monogram.presentation.features.stories.storyViewerOverlayColor @Composable internal fun StoryViewerChromeComponent( @@ -445,8 +474,8 @@ private fun StoryViewerActionsPopup( story: StoryModel?, currentUserId: Long?, isPremiumUser: Boolean, - stealthMode: org.monogram.domain.models.stories.StoryStealthModeModel, - storyOptions: org.monogram.domain.models.stories.StoryOptionsModel, + stealthMode: StoryStealthModeModel, + storyOptions: StoryOptionsModel, canManageStories: Boolean, viewerSource: StoryViewerSource, activeListType: StoryListType, diff --git a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt index eb21dbc13..22c32cb2a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stories/components/StoryViewerSceneComponents.kt @@ -1,4 +1,4 @@ -package org.monogram.presentation.features.stories +package org.monogram.presentation.features.stories.components import android.app.Activity import android.content.Context @@ -74,6 +74,7 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.graphics.createBitmap @@ -90,11 +91,29 @@ import androidx.media3.ui.PlayerView import coil3.compose.AsyncImage import coil3.request.ImageRequest import kotlinx.coroutines.delay +import org.koin.compose.koinInject import org.monogram.domain.models.stories.StoryAreaTypeModel import org.monogram.domain.models.stories.StoryMediaType import org.monogram.domain.models.stories.StoryModel +import org.monogram.domain.models.stories.StoryReactionModel import org.monogram.presentation.R +import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.profile.components.StatisticsViewer +import org.monogram.presentation.features.stories.STORY_MEDIA_ASPECT_RATIO +import org.monogram.presentation.features.stories.StoriesHostComponent +import org.monogram.presentation.features.stories.StoryInteractionsSheet +import org.monogram.presentation.features.stories.StoryLinksSheet +import org.monogram.presentation.features.stories.StoryReactionPickerSheet +import org.monogram.presentation.features.stories.StoryViewerBorderColor +import org.monogram.presentation.features.stories.StoryViewerChrome +import org.monogram.presentation.features.stories.StoryViewerContentColor +import org.monogram.presentation.features.stories.formatCompactStoryCount +import org.monogram.presentation.features.stories.resolveStoryAutoAdvanceDurationMs +import org.monogram.presentation.features.stories.resolveStoryDownloadPath +import org.monogram.presentation.features.stories.shouldRestartCurrentStoryFromPreviousTap +import org.monogram.presentation.features.stories.storyAreaPrimaryLabel +import org.monogram.presentation.features.stories.storyAreaSecondaryLabel +import org.monogram.presentation.features.stories.storyViewerOverlayColor import java.io.File @Composable @@ -104,8 +123,8 @@ internal fun StoryViewerScaffoldComponent( story: StoryModel? ) { val context = LocalContext.current - val downloadUtils: org.monogram.presentation.core.util.IDownloadUtils = - org.koin.compose.koinInject() + val downloadUtils: IDownloadUtils = + koinInject() var currentProgress by remember(story?.id) { mutableFloatStateOf(0f) } var restartPlaybackToken by remember(story?.id) { mutableStateOf(0) } var isVideoMuted by remember(story?.id) { mutableStateOf(false) } @@ -115,7 +134,7 @@ internal fun StoryViewerScaffoldComponent( var isLinksSheetVisible by remember(story?.id) { mutableStateOf(false) } var playerView by remember(story?.id) { mutableStateOf(null) } var selectedReactionOverride by remember(story?.id) { - mutableStateOf(null) + mutableStateOf(null) } val selectedReaction = selectedReactionOverride ?: story?.chosenReaction @@ -362,7 +381,7 @@ private fun StoryMediaScene( onPreviousTap: () -> Unit, onNextTap: () -> Unit, onStoryAreaClick: (StoryAreaTypeModel) -> Unit, - selectedReaction: org.monogram.domain.models.stories.StoryReactionModel?, + selectedReaction: StoryReactionModel?, onPlayerViewChanged: (PlayerView?) -> Unit ) { val story = page.story @@ -548,7 +567,7 @@ private fun StoryViewerTapZones( private fun StoryAreaOverlays( story: StoryModel, onAreaClick: (StoryAreaTypeModel) -> Unit, - selectedReaction: org.monogram.domain.models.stories.StoryReactionModel? + selectedReaction: StoryReactionModel? ) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { story.areas.forEachIndexed { index, area -> @@ -556,9 +575,9 @@ private fun StoryAreaOverlays( maxOf(maxWidth * (area.position.widthPercentage.toFloat() / 100f), 72.dp) val baseHeight = maxOf(maxHeight * (area.position.heightPercentage.toFloat() / 100f), 34.dp) - val width: androidx.compose.ui.unit.Dp - val height: androidx.compose.ui.unit.Dp - val cornerRadius: androidx.compose.ui.unit.Dp + val width: Dp + val height: Dp + val cornerRadius: Dp if (area.type is StoryAreaTypeModel.SuggestedReaction) { val diameter = maxOf(baseHeight, 58.dp) @@ -598,7 +617,7 @@ private fun StoryAreaOverlays( private fun StoryAreaChip( modifier: Modifier, areaType: StoryAreaTypeModel, - cornerRadius: androidx.compose.ui.unit.Dp, + cornerRadius: Dp, onClick: () -> Unit, key: String, isSelected: Boolean diff --git a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt index 779f24c0d..7265a01d0 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt @@ -21,12 +21,14 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import org.json.JSONObject @@ -504,15 +506,13 @@ class DefaultRootComponent( initialCaption: String = "", widgetLink: String? = null ) { - scope.launch { - storiesHost.openComposer( - chatId = chatId, - preferredMediaType = preferredMediaType, - initialSourcePath = initialSourcePath, - initialCaption = initialCaption, - widgetLink = widgetLink - ) - } + storiesHost.openComposer( + chatId = chatId, + preferredMediaType = preferredMediaType, + initialSourcePath = initialSourcePath, + initialCaption = initialCaption, + widgetLink = widgetLink + ) } private fun openOwnStoryComposer( @@ -521,8 +521,21 @@ class DefaultRootComponent( initialCaption: String = "", widgetLink: String? = null ) { + userRepository.currentUserFlow.value?.id?.let { currentUserId -> + openStoryComposer( + chatId = currentUserId, + preferredMediaType = preferredMediaType, + initialSourcePath = initialSourcePath, + initialCaption = initialCaption, + widgetLink = widgetLink + ) + return + } + scope.launch { - val me = userRepository.getMe() ?: return@launch + val me = withTimeoutOrNull(250) { + userRepository.currentUserFlow.filterNotNull().first() + } ?: userRepository.getMe() openStoryComposer( chatId = me.id, preferredMediaType = preferredMediaType, diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 959714b70..b7ff2ff88 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -2403,6 +2403,13 @@ Все Контакты Близкие друзья + Выбранные пользователи + Показывать + Скрыть от + Выберите, кто сможет увидеть эту историю + История видна всей выбранной аудитории + Настроить + Очистить Видна в течение 6 ч 12 ч @@ -2467,6 +2474,13 @@ Необязательный текст, отображаемый над вашей историей Настройки истории Выберите, как долго эта история будет видна + Выберите пользователей + Скрыть от пользователей + Эту историю увидят только выбранные контакты + Исключите контакты из выбранной аудитории + Поиск контактов + Контакты не найдены + Подходящие контакты не найдены Показывать эту историю в вашем профиле после публикации Убрать из профиля Запретить пользователям пересылать и сохранять историю @@ -2474,6 +2488,25 @@ Эта история содержит ссылку на мини-приложение Ссылки Статистика истории + Выберите хотя бы одного пользователя для этой истории + + Видна %d пользователю + Видна %d пользователям + Видна %d пользователям + Видна %d пользователям + + + Скрыта от %d пользователя + Скрыта от %d пользователей + Скрыта от %d пользователей + Скрыта от %d пользователей + + + Выбран %d пользователь + Выбрано %d пользователя + Выбрано %d пользователей + Выбрано %d пользователей + Просмотры и пересылки Реакции Взаимодействия с историей diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 132fbd62c..02bd54eda 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -575,6 +575,13 @@ Everyone Contacts Close friends + Selected users + Show to + Hide from + Choose who can view this story + Visible to everyone in this audience + Manage + Clear Visible for 6h 12h @@ -639,6 +646,13 @@ Optional text shown above your story Story settings Pick how long this story stays visible + Select viewers + Hide from users + Only selected contacts will be able to view this story + Exclude contacts from the selected audience + Search contacts + No contacts available + No matching contacts Show this story on your profile after publishing Remove from profile Restrict forwarding and saving for viewers @@ -646,6 +660,19 @@ This story includes a mini app link Links Story Statistics + Select at least one user for this story + + Visible to %d user + Visible to %d users + + + Hidden from %d user + Hidden from %d users + + + %d selected + %d selected + Views and Shares Reactions Stealth mode