Repository: 0xZhangKe/Fread Branch: main Commit: 720d2eceb455 Files: 1343 Total size: 3.7 MB Directory structure: gitextract_2f1tk_0x/ ├── .codex/ │ └── skills/ │ ├── screen2navkey/ │ │ └── SKILL.md │ └── voyager2nav3/ │ └── SKILL.md ├── .editorconfig ├── .github/ │ ├── FUNDING.yml │ ├── ci-gradle.properties │ └── workflows/ │ ├── build_apk.yml │ └── check.yml ├── .gitignore ├── Agents.md ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ ├── FreadAndroidApplication.kt │ │ └── screen/ │ │ └── FreadActivity.kt │ └── res/ │ ├── drawable/ │ │ ├── ic_launcher_foreground.xml │ │ └── shape_alert_dialog_background.xml │ ├── mipmap-anydpi-v26/ │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── values/ │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── values-zh/ │ └── strings.xml ├── app-hosting/ │ ├── build.gradle.kts │ └── src/ │ ├── androidMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ ├── HostingApplication.kt │ │ ├── composable/ │ │ │ └── LoadingPage.android.kt │ │ ├── di/ │ │ │ ├── HostingModule.android.kt │ │ │ └── PlatformedFreadApplication.android.kt │ │ ├── screen/ │ │ │ └── DeviceCornerRadius.android.kt │ │ └── utils/ │ │ └── ActivityHelper.android.kt │ ├── commonMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ ├── CommonNavEntryProvider.kt │ │ ├── auth/ │ │ │ └── AuthenticationPage.kt │ │ ├── composable/ │ │ │ └── LoadingPage.kt │ │ ├── di/ │ │ │ ├── FreadApplication.kt │ │ │ └── HostingModule.kt │ │ ├── screen/ │ │ │ ├── DeviceCornerRadius.kt │ │ │ ├── FreadApp.kt │ │ │ ├── FreadScreen.kt │ │ │ ├── NavDisplayTransitions.kt │ │ │ ├── PredictiveBackEntryDecorator.kt │ │ │ └── main/ │ │ │ ├── MainPageUiState.kt │ │ │ ├── MainViewModel.kt │ │ │ └── drawer/ │ │ │ ├── MainDrawer.kt │ │ │ ├── MainDrawerUiState.kt │ │ │ └── MainDrawerViewModel.kt │ │ └── utils/ │ │ └── ActivityHelper.kt │ └── iosMain/ │ └── kotlin/ │ └── com/ │ └── zhangke/ │ └── fread/ │ ├── di/ │ │ ├── HostingModule.ios.kt │ │ └── PlatformedFreadApplication.ios.kt │ ├── screen/ │ │ ├── DeviceCornerRadius.ios.kt │ │ ├── FreadViewController.kt │ │ └── IosFreadApp.kt │ ├── startup/ │ │ └── KRouterStartup.kt │ └── utils/ │ └── ActivityHelper.ios.kt ├── appprivacy.html ├── bizframework/ │ └── status-provider/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── consumer-rules.pro │ ├── proguard-rules.pro │ └── src/ │ ├── androidMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ └── status/ │ │ ├── richtext/ │ │ │ └── RichTextBuilder.android.kt │ │ └── utils/ │ │ └── ImplementerFinder.kt │ ├── commonMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ └── status/ │ │ ├── StatusProvider.kt │ │ ├── StatusProviderModel.kt │ │ ├── account/ │ │ │ ├── AccountManager.kt │ │ │ ├── AuthenticationFailureException.kt │ │ │ └── LoggedAccount.kt │ │ ├── author/ │ │ │ └── BlogAuthor.kt │ │ ├── blog/ │ │ │ ├── Blog.kt │ │ │ ├── BlogEmbed.kt │ │ │ ├── BlogMedia.kt │ │ │ ├── BlogMediaMeta.kt │ │ │ ├── BlogMediaType.kt │ │ │ ├── BlogPoll.kt │ │ │ ├── BlogServer.kt │ │ │ ├── BlogTranslation.kt │ │ │ ├── CurrentUserQuoteApproval.kt │ │ │ └── PostingApplication.kt │ │ ├── content/ │ │ │ ├── ContentManager.kt │ │ │ └── MixedContent.kt │ │ ├── model/ │ │ │ ├── BlogFiltered.kt │ │ │ ├── ContentConfig.kt │ │ │ ├── ContentType.kt │ │ │ ├── Emoji.kt │ │ │ ├── FacetFeatureUnion.kt │ │ │ ├── FormattingTime.kt │ │ │ ├── FreadContent.kt │ │ │ ├── Hashtag.kt │ │ │ ├── HashtagInStatus.kt │ │ │ ├── LoggedAccountDetail.kt │ │ │ ├── Mention.kt │ │ │ ├── PagedData.kt │ │ │ ├── PlatformLocator.kt │ │ │ ├── PostInteractionSetting.kt │ │ │ ├── PublishBlogRules.kt │ │ │ ├── QuoteApprovalPolicy.kt │ │ │ ├── Relationships.kt │ │ │ ├── StatusActionType.kt │ │ │ ├── StatusList.kt │ │ │ ├── StatusProviderProtocol.kt │ │ │ ├── StatusUiState.kt │ │ │ └── StatusVisibility.kt │ │ ├── notification/ │ │ │ ├── NotificationResolver.kt │ │ │ └── StatusNotification.kt │ │ ├── platform/ │ │ │ ├── BlogPlatform.kt │ │ │ ├── PlatformResolver.kt │ │ │ └── PlatformSnapshot.kt │ │ ├── publish/ │ │ │ ├── PublishBlogManager.kt │ │ │ └── PublishingPost.kt │ │ ├── richtext/ │ │ │ ├── OnLinkTargetClick.kt │ │ │ ├── RichText.kt │ │ │ ├── RichTextBuilder.kt │ │ │ ├── model/ │ │ │ │ └── RichLinkTarget.kt │ │ │ └── parser/ │ │ │ ├── HtmlParser.kt │ │ │ └── PlaintextParser.kt │ │ ├── screen/ │ │ │ └── StatusScreenProvider.kt │ │ ├── search/ │ │ │ ├── SearchContentResult.kt │ │ │ ├── SearchEngine.kt │ │ │ └── SearchResult.kt │ │ ├── source/ │ │ │ ├── StatusSource.kt │ │ │ └── StatusSourceResolver.kt │ │ ├── status/ │ │ │ ├── StatusResolver.kt │ │ │ └── model/ │ │ │ ├── Status.kt │ │ │ └── StatusContext.kt │ │ ├── uri/ │ │ │ ├── FormalUri.kt │ │ │ └── FormalUriParser.kt │ │ └── utils/ │ │ ├── DateTimeFormatter.kt │ │ └── ResultKtx.kt │ └── commonTest/ │ └── kotlin/ │ └── com/ │ └── zhangke/ │ └── fread/ │ └── status/ │ ├── richtext/ │ │ └── parser/ │ │ └── HtmlParserTest.kt │ └── uri/ │ └── FormalUriTest.kt ├── build-logic/ │ ├── README.md │ ├── convention/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ └── kotlin/ │ │ ├── AndroidApplicationConventionPlugin.kt │ │ ├── AndroidLibraryConventionPlugin.kt │ │ ├── ComposeMultiPlatformConventionPlugin.kt │ │ ├── KotlinMultiplatformLibraryConventionPlugin.kt │ │ ├── Project.kt │ │ ├── ProjectFeatureKmpConventionPlugin.kt │ │ ├── ProjectFrameworkKmpConventionPlugin.kt │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ ├── KotlinAndroid.kt │ │ ├── PrintTestApks.kt │ │ └── ProjectExt.kt │ ├── gradle.properties │ └── settings.gradle.kts ├── build.gradle.kts ├── commonbiz/ │ ├── analytics/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── consumer-rules.pro │ │ ├── proguard-rules.pro │ │ └── src/ │ │ └── commonMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ └── analytics/ │ │ ├── Analytics.kt │ │ ├── EventNames.kt │ │ └── TrackingEventDataBuilder.kt │ ├── common/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── androidMain/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── zhangke/ │ │ │ │ └── fread/ │ │ │ │ └── common/ │ │ │ │ ├── AndroidCommonModule.kt │ │ │ │ ├── browser/ │ │ │ │ │ ├── AndroidSystemBrowserLauncher.kt │ │ │ │ │ └── OAuthLauncher.android.kt │ │ │ │ ├── daynight/ │ │ │ │ │ └── DayNightPlatformHelper.android.kt │ │ │ │ ├── di/ │ │ │ │ │ └── ApplicationContext.kt │ │ │ │ ├── handler/ │ │ │ │ │ └── TextHandler.android.kt │ │ │ │ ├── language/ │ │ │ │ │ ├── ActivityLanguageHelper.android.kt │ │ │ │ │ └── LanguageHelper.android.kt │ │ │ │ ├── page/ │ │ │ │ │ └── BasePagerTabHookManager.android.kt │ │ │ │ ├── startup/ │ │ │ │ │ └── LanguageModuleStartup.kt │ │ │ │ ├── update/ │ │ │ │ │ └── AppPlatformUpdater.android.kt │ │ │ │ └── utils/ │ │ │ │ ├── ActivityResultUtils.kt │ │ │ │ ├── MediaFileHelper.android.kt │ │ │ │ ├── PlatformUriHelper.android.kt │ │ │ │ ├── RandomIdGenerator.android.kt │ │ │ │ ├── ShareHelper.kt │ │ │ │ ├── StorageHelper.android.kt │ │ │ │ └── ToastHelper.android.kt │ │ │ └── res/ │ │ │ ├── anim/ │ │ │ │ ├── fade_in.xml │ │ │ │ └── fade_out.xml │ │ │ ├── drawable/ │ │ │ │ └── ic_logo_skeleton.xml │ │ │ └── values/ │ │ │ ├── colors.xml │ │ │ └── theme.xml │ │ ├── commonMain/ │ │ │ ├── composeResources/ │ │ │ │ └── drawable/ │ │ │ │ ├── bluesky_logo.xml │ │ │ │ ├── ic_explorer.xml │ │ │ │ ├── ic_not_found_404.xml │ │ │ │ ├── mastodon_black_text.xml │ │ │ │ ├── mastodon_logo.xml │ │ │ │ └── mastodon_white_text.xml │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── zhangke/ │ │ │ └── fread/ │ │ │ └── common/ │ │ │ ├── CommonModule.kt │ │ │ ├── CommonNavEntryProvider.kt │ │ │ ├── CommonStartup.kt │ │ │ ├── MixedContentJsonBuilder.kt │ │ │ ├── account/ │ │ │ │ └── ActiveAccountsSynchronizer.kt │ │ │ ├── action/ │ │ │ │ ├── ComposableActions.kt │ │ │ │ ├── RouteAction.kt │ │ │ │ └── RouteActions.kt │ │ │ ├── adapter/ │ │ │ │ └── StatusUiStateAdapter.kt │ │ │ ├── browser/ │ │ │ │ ├── BrowserInterceptor.kt │ │ │ │ ├── BrowserLauncher.kt │ │ │ │ ├── OAuthHandler.kt │ │ │ │ ├── SystemBrowserLauncher.kt │ │ │ │ └── UrlRedirectScreen.kt │ │ │ ├── bubble/ │ │ │ │ ├── Bubble.kt │ │ │ │ └── BubbleManager.kt │ │ │ ├── composable/ │ │ │ │ ├── EmptyContent.kt │ │ │ │ ├── ErrorContent.kt │ │ │ │ └── SelectableAccount.kt │ │ │ ├── config/ │ │ │ │ ├── AppCommonConfig.kt │ │ │ │ ├── FreadConfigManager.kt │ │ │ │ ├── LocalConfigManager.kt │ │ │ │ ├── StatusConfig.kt │ │ │ │ ├── StatusContentSize.kt │ │ │ │ └── TimelineDefaultPosition.kt │ │ │ ├── content/ │ │ │ │ ├── FreadContentDbMigrateManager.kt │ │ │ │ └── FreadContentRepo.kt │ │ │ ├── daynight/ │ │ │ │ ├── DayNightHelper.kt │ │ │ │ └── DayNightMode.kt │ │ │ ├── db/ │ │ │ │ ├── ContentConfigDatabases.kt │ │ │ │ ├── FreadContentDatabase.kt │ │ │ │ ├── MixedStatusDatabases.kt │ │ │ │ ├── converts/ │ │ │ │ │ ├── BlogMediaConverterHelper.kt │ │ │ │ │ ├── BlogMediaListConverter.kt │ │ │ │ │ ├── BlogPollConverter.kt │ │ │ │ │ ├── ContentTabConverter.kt │ │ │ │ │ ├── ContentTypeConverter.kt │ │ │ │ │ ├── FormalBaseUrlConverter.kt │ │ │ │ │ ├── FormalUriConverter.kt │ │ │ │ │ ├── FreadContentConverter.kt │ │ │ │ │ ├── PlatformLocatorConverter.kt │ │ │ │ │ ├── StatusConverter.kt │ │ │ │ │ ├── StatusNotificationConverter.kt │ │ │ │ │ ├── StatusProviderUriListConverter.kt │ │ │ │ │ └── StatusUiStateConverter.kt │ │ │ │ └── old/ │ │ │ │ └── OldFreadContentDatabase.kt │ │ │ ├── deeplink/ │ │ │ │ ├── ExternalInputHandler.kt │ │ │ │ ├── ExternalInputParser.kt │ │ │ │ ├── SelectAccountScreen.kt │ │ │ │ └── SelectedContentSwitcher.kt │ │ │ ├── di/ │ │ │ │ └── ApplicationCoroutineScope.kt │ │ │ ├── feeds/ │ │ │ │ └── model/ │ │ │ │ └── RefreshResult.kt │ │ │ ├── handler/ │ │ │ │ └── TextHandler.kt │ │ │ ├── language/ │ │ │ │ ├── LanguageHelper.kt │ │ │ │ └── LanguageSettingItem.kt │ │ │ ├── mixed/ │ │ │ │ └── MixedStatusRepo.kt │ │ │ ├── onboarding/ │ │ │ │ └── OnboardingComponent.kt │ │ │ ├── page/ │ │ │ │ └── BasePagerTabHookManager.kt │ │ │ ├── publish/ │ │ │ │ └── PublishPostManager.kt │ │ │ ├── push/ │ │ │ │ └── IPushManager.kt │ │ │ ├── repo/ │ │ │ │ └── LinkPreviewCardRepo.kt │ │ │ ├── resources/ │ │ │ │ └── ProtocolsSymbol.kt │ │ │ ├── review/ │ │ │ │ ├── DefaultAppStoreReviewer.kt │ │ │ │ └── FreadReviewManager.kt │ │ │ ├── startup/ │ │ │ │ ├── FeedsRepoModuleStartup.kt │ │ │ │ ├── FreadConfigModuleStartup.kt │ │ │ │ └── StartupManager.kt │ │ │ ├── status/ │ │ │ │ ├── StatusConfiguration.kt │ │ │ │ ├── StatusIdGenerator.kt │ │ │ │ ├── StatusUpdater.kt │ │ │ │ ├── adapter/ │ │ │ │ │ └── ContentConfigAdapter.kt │ │ │ │ ├── model/ │ │ │ │ │ └── SearchResultUiState.kt │ │ │ │ └── usecase/ │ │ │ │ └── FormatStatusDisplayTimeUseCase.kt │ │ │ ├── theme/ │ │ │ │ └── ThemeType.kt │ │ │ ├── update/ │ │ │ │ ├── AppPlatformUpdater.kt │ │ │ │ ├── AppReleaseInfo.kt │ │ │ │ └── AppUpdateManager.kt │ │ │ └── utils/ │ │ │ ├── GlobalScreenNavigation.kt │ │ │ ├── HashtagTextUtils.kt │ │ │ ├── InstantExt.kt │ │ │ ├── LinkTextUtils.kt │ │ │ ├── ListStringConverter.kt │ │ │ ├── MediaFileHelper.kt │ │ │ ├── MentionTextUtil.kt │ │ │ ├── PlatformUriHelper.kt │ │ │ ├── RandomIdGenerator.kt │ │ │ ├── StorageHelper.kt │ │ │ ├── ToastHelper.kt │ │ │ └── WebFingerConverter.kt │ │ ├── commonTest/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── zhangke/ │ │ │ └── fread/ │ │ │ └── common/ │ │ │ ├── status/ │ │ │ │ └── utils/ │ │ │ │ └── createStatus.kt │ │ │ └── utils/ │ │ │ ├── DateTimeFormatterTest.kt │ │ │ ├── FormalUriTest.kt │ │ │ └── HashtagTextUtilsTest.kt │ │ └── iosMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ └── common/ │ │ ├── CommonIosModule.kt │ │ ├── browser/ │ │ │ ├── IosSystemBrowserLauncher.kt │ │ │ └── OAuthLauncher.ios.kt │ │ ├── daynight/ │ │ │ └── DayNightPlatformHelper.ios.kt │ │ ├── handler/ │ │ │ └── TextHandler.ios.kt │ │ ├── language/ │ │ │ └── LanguageHelper.ios.kt │ │ ├── page/ │ │ │ └── BasePagerTabHookManager.ios.kt │ │ ├── update/ │ │ │ └── AppPlatformUpdater.ios.kt │ │ └── utils/ │ │ ├── MediaFileHelper.ios.kt │ │ ├── PlatformUriHelper.ios.kt │ │ ├── RandomIdGenerator.ios.kt │ │ ├── StorageHelper.ios.kt │ │ ├── SystemUtils.kt │ │ └── ToastHelper.ios.kt │ ├── sharedscreen/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── proguard-rules.pro │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── zhangke/ │ │ │ └── fread/ │ │ │ └── commonbiz/ │ │ │ └── shared/ │ │ │ ├── SharedScreenAndroidEntryProvider.kt │ │ │ ├── SharedScreenAndroidModule.kt │ │ │ ├── composable/ │ │ │ │ └── WebViewPreviewer.android.kt │ │ │ └── screen/ │ │ │ └── ImageViewerScreen.android.kt │ │ ├── commonMain/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── zhangke/ │ │ │ └── fread/ │ │ │ └── commonbiz/ │ │ │ └── shared/ │ │ │ ├── ModuleScreenVisitor.kt │ │ │ ├── SharedScreenModule.kt │ │ │ ├── SharedScreenNavEntryProvider.kt │ │ │ ├── blog/ │ │ │ │ └── detail/ │ │ │ │ ├── RssBlogDetailScreen.kt │ │ │ │ └── RssBlogDetailViewModel.kt │ │ │ ├── composable/ │ │ │ │ ├── BlogUi.kt │ │ │ │ ├── FeedsContent.kt │ │ │ │ ├── FeedsStatusNode.kt │ │ │ │ ├── ObserveForFeedsConnection.kt │ │ │ │ ├── OnBlogMediaClick.kt │ │ │ │ ├── SearchResultUi.kt │ │ │ │ ├── UserInfoCard.kt │ │ │ │ └── WebViewPreviewer.kt │ │ │ ├── db/ │ │ │ │ └── SelectedAccountPublishingDatabase.kt │ │ │ ├── feeds/ │ │ │ │ ├── CommonFeedsUiState.kt │ │ │ │ ├── FeedsViewModelController.kt │ │ │ │ ├── IFeedsViewModelController.kt │ │ │ │ ├── IInteractiveHandler.kt │ │ │ │ ├── InteractiveHandleResult.kt │ │ │ │ └── InteractiveHandler.kt │ │ │ ├── notification/ │ │ │ │ ├── FollowNotification.kt │ │ │ │ ├── FollowRequestNotification.kt │ │ │ │ ├── NotificationHeadLine.kt │ │ │ │ ├── NotificationWithWholeStatus.kt │ │ │ │ ├── SeveredRelationshipsNotification.kt │ │ │ │ ├── StatusNotificationUi.kt │ │ │ │ └── UnknownNotification.kt │ │ │ ├── repo/ │ │ │ │ └── SelectedAccountPublishingRepo.kt │ │ │ ├── screen/ │ │ │ │ ├── ImageViewerScreen.kt │ │ │ │ ├── SelectLanguageScreen.kt │ │ │ │ ├── publish/ │ │ │ │ │ ├── PublishBlogScreen.kt │ │ │ │ │ ├── PublishBlogUiState.kt │ │ │ │ │ ├── PublishPostBottomPanel.kt │ │ │ │ │ ├── PublishPostMediaAttachment.kt │ │ │ │ │ ├── PublishPostScaffold.kt │ │ │ │ │ ├── PublishSettingLabel.kt │ │ │ │ │ ├── PublishTopBar.kt │ │ │ │ │ ├── composable/ │ │ │ │ │ │ ├── AvatarsHorizontalStack.kt │ │ │ │ │ │ ├── BlogMediaAttachment.kt │ │ │ │ │ │ ├── InputBlogTextField.kt │ │ │ │ │ │ ├── PostInteractionSettingLabel.kt │ │ │ │ │ │ ├── PostStatusVisibilityUi.kt │ │ │ │ │ │ └── PostStatusWarning.kt │ │ │ │ │ ├── model/ │ │ │ │ │ │ └── PublishBlogMediaAttachment.kt │ │ │ │ │ └── multi/ │ │ │ │ │ ├── MultiAccountPublishingScreen.kt │ │ │ │ │ ├── MultiAccountPublishingUiState.kt │ │ │ │ │ ├── MultiAccountPublishingViewModel.kt │ │ │ │ │ └── PublishingAccounts.kt │ │ │ │ ├── search/ │ │ │ │ │ ├── AbstractSearchStatusScreen.kt │ │ │ │ │ ├── AbstractSearchStatusViewModel.kt │ │ │ │ │ └── SearchStatusUiState.kt │ │ │ │ ├── status/ │ │ │ │ │ ├── account/ │ │ │ │ │ │ ├── SelectAccountOpenStatusScreen.kt │ │ │ │ │ │ ├── SelectAccountOpenStatusUiState.kt │ │ │ │ │ │ └── SelectAccountOpenStatusViewModel.kt │ │ │ │ │ └── context/ │ │ │ │ │ ├── StatusContextScreen.kt │ │ │ │ │ ├── StatusContextSubViewModel.kt │ │ │ │ │ ├── StatusContextUiState.kt │ │ │ │ │ └── StatusContextViewModel.kt │ │ │ │ └── video/ │ │ │ │ └── FullVideoScreen.kt │ │ │ ├── usecase/ │ │ │ │ ├── PublishPostOnMultiAccountUseCase.kt │ │ │ │ ├── RefactorToNewBlogUseCase.kt │ │ │ │ └── RefactorToNewStatusUseCase.kt │ │ │ └── utils/ │ │ │ └── LoadableStatusController.kt │ │ └── iosMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ └── commonbiz/ │ │ └── shared/ │ │ ├── SharedScreenIosModule.kt │ │ ├── composable/ │ │ │ └── WebViewPreviewer.ios.kt │ │ └── screen/ │ │ └── ImageViewerScreen.ios.kt │ └── status-ui/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── consumer-rules.pro │ ├── proguard-rules.pro │ └── src/ │ ├── androidMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ └── status/ │ │ └── ui/ │ │ ├── StatusPlaceHolder.preview.kt │ │ ├── common/ │ │ │ └── FormattingTimeText.kt │ │ ├── poll/ │ │ │ └── BlogPollOption.preview.kt │ │ ├── utils/ │ │ │ └── ScreenSize.android.kt │ │ └── video/ │ │ ├── BlogVideos.kt │ │ ├── LocalInlineVideoPlayer.kt │ │ └── inline/ │ │ └── InlineVideo.kt │ ├── commonMain/ │ │ ├── composeResources/ │ │ │ └── drawable/ │ │ │ ├── ic_drag_indicator.xml │ │ │ ├── ic_format_quote.xml │ │ │ ├── ic_format_quote_in_left.xml │ │ │ ├── ic_mode_edit.xml │ │ │ ├── ic_more.xml │ │ │ ├── ic_post_status_spoiler.xml │ │ │ ├── ic_share.xml │ │ │ ├── ic_status_comment.xml │ │ │ ├── ic_status_forward.xml │ │ │ ├── img_banner_background.xml │ │ │ └── status_ui_baseline_visibility_off_24.xml │ │ └── kotlin/ │ │ ├── com/ │ │ │ └── zhangke/ │ │ │ └── fread/ │ │ │ └── status/ │ │ │ └── ui/ │ │ │ ├── BlogAuthorAvatar.kt │ │ │ ├── BlogAuthorUi.kt │ │ │ ├── BlogContent.kt │ │ │ ├── BlogDivider.kt │ │ │ ├── BlogUi.kt │ │ │ ├── ComposedStatusInteraction.kt │ │ │ ├── StatusInfoLine.kt │ │ │ ├── StatusPlaceHolder.kt │ │ │ ├── StatusUi.kt │ │ │ ├── action/ │ │ │ │ ├── ModalDropdownMenuItem.kt │ │ │ │ ├── StatusActions.kt │ │ │ │ ├── StatusBottomInteractionPanel.kt │ │ │ │ ├── StatusIconButton.kt │ │ │ │ ├── StatusInteractiveExts.kt │ │ │ │ └── StatusMoreInteractionPanel.kt │ │ │ ├── bar/ │ │ │ │ └── EditContentTopBar.kt │ │ │ ├── common/ │ │ │ │ ├── BlogTranslaction.kt │ │ │ │ ├── ContentToolbar.kt │ │ │ │ ├── DetailHeaderContent.kt │ │ │ │ ├── DetailPageScaffold.kt │ │ │ │ ├── DetailTopBar.kt │ │ │ │ ├── HomeContentTabsTopBar.kt │ │ │ │ ├── LinkPreviewCard.kt │ │ │ │ ├── NestedTabConnection.kt │ │ │ │ ├── NewStatusNotifyBar.kt │ │ │ │ ├── ObserveMaxReadItem.kt │ │ │ │ ├── ObserveScrollStopedPosition.kt │ │ │ │ ├── PostStatusTextVisualTransformation.kt │ │ │ │ ├── ProgressedAvatar.kt │ │ │ │ ├── ProgressedBanner.kt │ │ │ │ ├── PublishingFab.kt │ │ │ │ ├── RelationshipStateButton.kt │ │ │ │ ├── RemainingTextStatus.kt │ │ │ │ ├── SelectAccountDialog.kt │ │ │ │ ├── StatusSharedElementConfig.kt │ │ │ │ └── UserFollowLine.kt │ │ │ ├── embed/ │ │ │ │ ├── BlogEmbedsUi.kt │ │ │ │ ├── BlogInEmbedding.kt │ │ │ │ ├── StatusEmbedLinkUi.kt │ │ │ │ └── UnavailableQuoteInEmbedding.kt │ │ │ ├── hashtag/ │ │ │ │ └── HashtagUi.kt │ │ │ ├── image/ │ │ │ │ ├── BlogImageMedia.kt │ │ │ │ ├── DoubleBlogImageLayout.kt │ │ │ │ ├── FivefoldImageMediaFrameLayout.kt │ │ │ │ ├── HorizontalImageMediaFrameLayout.kt │ │ │ │ ├── HorizontalImageMediaListLayout.kt │ │ │ │ ├── QuadrupleImageMediaLayout.kt │ │ │ │ ├── SingleBlogImageLayout.kt │ │ │ │ ├── SixfoldImageMediaLayout.kt │ │ │ │ ├── TripleImageMediaLayout.kt │ │ │ │ ├── VerticalImageMediaFrameLayout.kt │ │ │ │ └── VerticalImageMediaListLayout.kt │ │ │ ├── label/ │ │ │ │ ├── StatusBottomEditedLabel.kt │ │ │ │ ├── StatusBottomInteractionLabel.kt │ │ │ │ ├── StatusBottomTimeLabel.kt │ │ │ │ └── StatusTopLabel.kt │ │ │ ├── media/ │ │ │ │ └── BlogMedias.kt │ │ │ ├── placeholder/ │ │ │ │ └── ListWithAvatarPlaceholder.kt │ │ │ ├── poll/ │ │ │ │ ├── BlogPoll.kt │ │ │ │ ├── BlogPollOption.kt │ │ │ │ ├── MultipleChoicePoll.kt │ │ │ │ └── SingleChoicePoll.kt │ │ │ ├── publish/ │ │ │ │ ├── BlogInQuoting.kt │ │ │ │ ├── NameAndAccountInfo.kt │ │ │ │ └── PublishBlogStyle.kt │ │ │ ├── richtext/ │ │ │ │ ├── FreadRichText.kt │ │ │ │ └── RichTextWithIcon.kt │ │ │ ├── source/ │ │ │ │ ├── BlogPlatformUi.kt │ │ │ │ ├── SearchPlatformResultUi.kt │ │ │ │ ├── SourceCommonUi.kt │ │ │ │ └── StatusSourceUi.kt │ │ │ ├── style/ │ │ │ │ ├── LocalStatusStyle.kt │ │ │ │ ├── StatusInfoStyle.kt │ │ │ │ ├── StatusStyle.kt │ │ │ │ └── StatusUiConfig.kt │ │ │ ├── threads/ │ │ │ │ ├── Threads.kt │ │ │ │ └── ThreadsType.kt │ │ │ ├── update/ │ │ │ │ └── AppUpdateDialog.kt │ │ │ ├── user/ │ │ │ │ ├── CommonUserUi.kt │ │ │ │ └── UserHandleLine.kt │ │ │ ├── utils/ │ │ │ │ ├── CardInfoSection.kt │ │ │ │ └── ScreenSize.kt │ │ │ └── video/ │ │ │ ├── BlogVideos.kt │ │ │ ├── VideoDurationFormatter.kt │ │ │ └── full/ │ │ │ └── FullScreenVideoPlayer.kt │ │ └── org/ │ │ └── burnoutcrew/ │ │ └── reorderable/ │ │ ├── DetectReorder.kt │ │ ├── DragCancelledAnimation.kt │ │ ├── DragGesture.kt │ │ ├── ItemPosition.kt │ │ ├── Reorderable.kt │ │ ├── ReorderableItem.kt │ │ ├── ReorderableLazyGridState.kt │ │ ├── ReorderableLazyListState.kt │ │ └── ReorderableState.kt │ └── iosMain/ │ └── kotlin/ │ └── com/ │ └── zhangke/ │ └── fread/ │ └── status/ │ └── ui/ │ ├── utils/ │ │ └── ScreenSize.ios.kt │ └── video/ │ └── BlogVideos.ios.kt ├── deleteuserdata.html ├── di-dependencis.md ├── documents/ │ ├── UserSource.drawio │ └── architecture.xmind ├── fastlane/ │ ├── Fastfile │ └── metadata/ │ └── android/ │ ├── en-US/ │ │ ├── full_description.txt │ │ └── short_description.txt │ └── zh-CN/ │ ├── full_description.txt │ └── short_description.txt ├── feature/ │ ├── explore/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── consumer-rules.pro │ │ ├── proguard-rules.pro │ │ └── src/ │ │ └── commonMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ └── explore/ │ │ ├── ExploreNavEntryProvider.kt │ │ ├── ExplorerElements.kt │ │ ├── di/ │ │ │ └── ExploreModule.kt │ │ ├── model/ │ │ │ └── ExplorerItem.kt │ │ ├── screens/ │ │ │ ├── home/ │ │ │ │ ├── ExploreTab.kt │ │ │ │ ├── ExplorerHomeUiState.kt │ │ │ │ ├── ExplorerHomeViewModel.kt │ │ │ │ └── ExplorerScreen.kt │ │ │ └── search/ │ │ │ ├── SearchScreen.kt │ │ │ ├── SearchUiState.kt │ │ │ ├── SearchViewModel.kt │ │ │ ├── author/ │ │ │ │ ├── SearchAuthorViewModel.kt │ │ │ │ └── SearchedAuthorTab.kt │ │ │ ├── bar/ │ │ │ │ ├── ExplorerSearchBar.kt │ │ │ │ ├── SearchBarUiState.kt │ │ │ │ └── SearchBarViewModel.kt │ │ │ ├── hashtag/ │ │ │ │ ├── SearchHashtagViewModel.kt │ │ │ │ └── SearchedHashtagTab.kt │ │ │ ├── platform/ │ │ │ │ ├── SearchPlatformViewModel.kt │ │ │ │ ├── SearchedPlatformTab.kt │ │ │ │ └── SearchedPlatformUiState.kt │ │ │ └── status/ │ │ │ ├── SearchStatusViewModel.kt │ │ │ └── SearchedStatusTab.kt │ │ └── usecase/ │ │ └── BuildSearchResultUiStateUseCase.kt │ ├── feeds/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── consumer-rules.pro │ │ ├── proguard-rules.pro │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── zhangke/ │ │ │ └── fread/ │ │ │ └── feeds/ │ │ │ └── pages/ │ │ │ └── manager/ │ │ │ └── importing/ │ │ │ └── OpenDocumentContainer.android.kt │ │ ├── commonMain/ │ │ │ ├── composeResources/ │ │ │ │ └── drawable/ │ │ │ │ ├── ic_home.xml │ │ │ │ └── ic_import.xml │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── zhangke/ │ │ │ └── fread/ │ │ │ └── feeds/ │ │ │ ├── FeedsNavEntryProvider.kt │ │ │ ├── FeedsScreenVisitor.kt │ │ │ ├── composable/ │ │ │ │ └── StatusSource.kt │ │ │ ├── di/ │ │ │ │ └── FeedsModule.kt │ │ │ └── pages/ │ │ │ ├── home/ │ │ │ │ ├── ContentHomeUiState.kt │ │ │ │ ├── ContentHomeViewModel.kt │ │ │ │ ├── FeedsContentHomeTab.kt │ │ │ │ ├── FeedsHomeTab.kt │ │ │ │ └── feeds/ │ │ │ │ ├── MixedContentSubViewModel.kt │ │ │ │ ├── MixedContentTab.kt │ │ │ │ ├── MixedContentUiState.kt │ │ │ │ └── MixedContentViewModel.kt │ │ │ └── manager/ │ │ │ ├── add/ │ │ │ │ ├── mixed/ │ │ │ │ │ ├── AddMixedFeedsScreen.kt │ │ │ │ │ ├── AddMixedFeedsUiState.kt │ │ │ │ │ └── AddMixedFeedsViewModel.kt │ │ │ │ └── type/ │ │ │ │ ├── SelectContentTypeScreen.kt │ │ │ │ └── SelectContentTypeViewModel.kt │ │ │ ├── edit/ │ │ │ │ ├── EditMixedContentScreen.kt │ │ │ │ ├── EditMixedContentUiState.kt │ │ │ │ └── EditMixedContentViewModel.kt │ │ │ ├── importing/ │ │ │ │ ├── ImportFeedsScreen.kt │ │ │ │ ├── ImportFeedsUiState.kt │ │ │ │ ├── ImportFeedsViewModel.kt │ │ │ │ └── OpenDocumentContainer.kt │ │ │ └── search/ │ │ │ ├── SearchForAddUiState.kt │ │ │ ├── SearchSourceForAddScreen.kt │ │ │ └── SearchSourceForAddViewModel.kt │ │ └── iosMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ └── feeds/ │ │ └── pages/ │ │ └── manager/ │ │ └── importing/ │ │ └── OpenDocumentContainer.ios.kt │ ├── notifications/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── consumer-rules.pro │ │ ├── proguard-rules.pro │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── zhangke/ │ │ │ └── fread/ │ │ │ └── feature/ │ │ │ └── message/ │ │ │ └── di/ │ │ │ └── NotificationsAndroidModule.kt │ │ ├── commonMain/ │ │ │ ├── composeResources/ │ │ │ │ └── drawable/ │ │ │ │ └── ic_notification_tab.xml │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── zhangke/ │ │ │ └── fread/ │ │ │ └── feature/ │ │ │ └── message/ │ │ │ ├── NotificationElements.kt │ │ │ ├── NotificationsNavEntryProvider.kt │ │ │ ├── di/ │ │ │ │ └── NotificationsModule.kt │ │ │ ├── repo/ │ │ │ │ └── notification/ │ │ │ │ ├── NotificationsDatabase.kt │ │ │ │ └── NotificationsRepo.kt │ │ │ └── screens/ │ │ │ ├── home/ │ │ │ │ ├── NotificationScreen.kt │ │ │ │ ├── NotificationsHomeUiState.kt │ │ │ │ ├── NotificationsHomeViewModel.kt │ │ │ │ └── NotificationsTab.kt │ │ │ └── notification/ │ │ │ ├── NotificationContainerViewModel.kt │ │ │ ├── NotificationTab.kt │ │ │ ├── NotificationUiState.kt │ │ │ └── NotificationViewModel.kt │ │ └── iosMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ └── feature/ │ │ └── message/ │ │ └── di/ │ │ └── NotificationsIosModule.kt │ └── profile/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── consumer-rules.pro │ ├── proguard-rules.pro │ └── src/ │ └── commonMain/ │ ├── composeResources/ │ │ └── drawable/ │ │ ├── ic_code.xml │ │ ├── ic_github_logo.xml │ │ ├── ic_profile_tab.xml │ │ ├── ic_ratting.xml │ │ ├── ic_telegram.xml │ │ └── kofi_symbol.xml │ └── kotlin/ │ └── com/ │ └── zhangke/ │ └── fread/ │ └── profile/ │ ├── ProfileNavEntryProvider.kt │ ├── ProfileScreenVisitor.kt │ ├── di/ │ │ └── ProfileModule.kt │ └── screen/ │ ├── donate/ │ │ └── DonateScreen.kt │ ├── home/ │ │ ├── ProfileHomeUiState.kt │ │ ├── ProfileHomeViewModel.kt │ │ ├── ProfileScreen.kt │ │ └── ProfileTab.kt │ ├── opensource/ │ │ └── OpenSourceScreen.kt │ └── setting/ │ ├── FeedbackBottomSheet.kt │ ├── SettingComponents.kt │ ├── SettingItemNames.kt │ ├── SettingScreen.kt │ ├── SettingScreenModel.kt │ ├── SettingUiState.kt │ ├── about/ │ │ ├── AboutScreen.kt │ │ ├── AboutUiState.kt │ │ └── AboutViewModel.kt │ ├── appearance/ │ │ ├── AppearanceSettingsScreen.kt │ │ ├── AppearanceSettingsUiState.kt │ │ └── AppearanceSettingsViewModel.kt │ └── behavior/ │ ├── BehaviorSettingsScreen.kt │ ├── BehaviorSettingsUiState.kt │ └── BehaviorSettingsViewModel.kt ├── framework/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── consumer-rules.pro │ ├── proguard-rules.pro │ └── src/ │ ├── androidInstrumentedTest/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── framework/ │ │ ├── ExampleInstrumentedTest.kt │ │ └── utils/ │ │ └── UtiUtilsTest.kt │ ├── androidMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── framework/ │ │ ├── activity/ │ │ │ └── TopActivityManager.kt │ │ ├── architect/ │ │ │ ├── http/ │ │ │ │ ├── GlobalOkHttpClient.kt │ │ │ │ └── HttpClientEngine.android.kt │ │ │ └── theme/ │ │ │ └── FreadTheme.android.kt │ │ ├── blurhash/ │ │ │ └── BitmapExt.android.kt │ │ ├── composable/ │ │ │ ├── AnimatableExt.kt │ │ │ ├── Loading.android.kt │ │ │ ├── TextString.android.kt │ │ │ └── pick/ │ │ │ └── PickVisualMediaLauncherContainer.android.kt │ │ ├── date/ │ │ │ └── InstantFormater.android.kt │ │ ├── datetime/ │ │ │ └── Instant.android.kt │ │ ├── ktx/ │ │ │ └── SingletonDelegate.kt │ │ ├── media/ │ │ │ └── MediaFileUtil.kt │ │ ├── permission/ │ │ │ ├── PermissionUtils.kt │ │ │ ├── RequireLocalStoragePermission.android.kt │ │ │ └── RequirePermission.kt │ │ ├── serialize/ │ │ │ └── DateSerializer.kt │ │ ├── toast/ │ │ │ └── Toast.android.kt │ │ └── utils/ │ │ ├── ActivityLifecycleCallbacksAdapter.kt │ │ ├── ActivityResultContractsExt.kt │ │ ├── BitmapUtils.kt │ │ ├── ContextUtils.kt │ │ ├── DensityUtils.android.kt │ │ ├── DrawableExt.kt │ │ ├── DrawableWrapper.kt │ │ ├── ExoPlayerUtils.kt │ │ ├── FileUtils.kt │ │ ├── IDNUtils.android.kt │ │ ├── ImageCompressUtils.android.kt │ │ ├── ImageLoaderUtils.kt │ │ ├── LanguageUtils.android.kt │ │ ├── PlatformIgnoredOnParcel.android.kt │ │ ├── PlatformParcelable.android.kt │ │ ├── PlatformTransient.android.kt │ │ ├── PlatformUri.android.kt │ │ ├── Serializable.android.kt │ │ ├── SignatureUtils.kt │ │ ├── SystemPageUtils.kt │ │ ├── SystemUtils.kt │ │ ├── UriUtils.android.kt │ │ └── VideoUtils.android.kt │ ├── androidUnitTest/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── framework/ │ │ ├── ExampleUnitTest.kt │ │ ├── RegexFactoryTest.kt │ │ ├── date/ │ │ │ └── DateParserTest.kt │ │ ├── feeds/ │ │ │ └── fetcher/ │ │ │ ├── FeedsFetcherTest.kt │ │ │ └── FeedsGeneratorTest.kt │ │ └── utils/ │ │ ├── RegexFactoryTest.kt │ │ ├── StorageSizeTest.kt │ │ └── WebFingerTest.kt │ ├── commonMain/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ ├── sd/ │ │ │ │ └── lib/ │ │ │ │ └── compose/ │ │ │ │ └── wheel_picker/ │ │ │ │ ├── WheelPicker.kt │ │ │ │ ├── WheelPickerDefault.kt │ │ │ │ └── WheelPickerState.kt │ │ │ └── zhangke/ │ │ │ └── framework/ │ │ │ ├── architect/ │ │ │ │ ├── coroutines/ │ │ │ │ │ ├── ApplicationScope.kt │ │ │ │ │ └── Flows.kt │ │ │ │ ├── http/ │ │ │ │ │ └── HttpClientEngine.kt │ │ │ │ ├── json/ │ │ │ │ │ ├── Json.kt │ │ │ │ │ └── JsonModuleBuilder.kt │ │ │ │ └── theme/ │ │ │ │ ├── Color.kt │ │ │ │ └── FreadTheme.kt │ │ │ ├── blur/ │ │ │ │ ├── BlurController.kt │ │ │ │ └── LocalEnableBlurAppBarStyle.kt │ │ │ ├── blurhash/ │ │ │ │ ├── BitmapExt.kt │ │ │ │ ├── BlurHashDecoder.kt │ │ │ │ └── BlurHashModifier.kt │ │ │ ├── collections/ │ │ │ │ └── Collections.kt │ │ │ ├── composable/ │ │ │ │ ├── AlertConfirmDialog.kt │ │ │ │ ├── AvatarStatck.kt │ │ │ │ ├── BackHandler.kt │ │ │ │ ├── BezierCurve.kt │ │ │ │ ├── BottomSheetState.kt │ │ │ │ ├── Bounds.kt │ │ │ │ ├── CollapsableTopBarScrollConnection.kt │ │ │ │ ├── CompositionLocal.kt │ │ │ │ ├── DatePickerDialog.kt │ │ │ │ ├── DirectionalLazyListState.kt │ │ │ │ ├── DpSaver.kt │ │ │ │ ├── DurationSelector.kt │ │ │ │ ├── FlowUtils.android.kt │ │ │ │ ├── FlowUtils.kt │ │ │ │ ├── FreadDialog.kt │ │ │ │ ├── FreadTabRow.kt │ │ │ │ ├── Grid.kt │ │ │ │ ├── HorizontalPageIndicator.kt │ │ │ │ ├── IconButton.kt │ │ │ │ ├── InsetAwareSearchBar.kt │ │ │ │ ├── Keyboard.kt │ │ │ │ ├── LazyListStateUtils.kt │ │ │ │ ├── LoadableLayout.kt │ │ │ │ ├── Loading.kt │ │ │ │ ├── LoadingDialog.kt │ │ │ │ ├── LocalContentPadding.kt │ │ │ │ ├── LocalSnackMessage.kt │ │ │ │ ├── NavigationBar.kt │ │ │ │ ├── NestedScrollConnection.kt │ │ │ │ ├── NoDoubleClick.kt │ │ │ │ ├── NoRippleClick.kt │ │ │ │ ├── Offset.kt │ │ │ │ ├── PaddingValuesUtils.kt │ │ │ │ ├── Placeholder.kt │ │ │ │ ├── PopupFloatingActionButton.kt │ │ │ │ ├── PopupMenu.kt │ │ │ │ ├── PredictiveBackProgressState.kt │ │ │ │ ├── ScreenUtils.kt │ │ │ │ ├── ScrollTopAppBar.kt │ │ │ │ ├── SearchToolbar.kt │ │ │ │ ├── Size.kt │ │ │ │ ├── SlickRoundCornerShape.kt │ │ │ │ ├── Snackbar.kt │ │ │ │ ├── StateSaver.kt │ │ │ │ ├── StyledIconButton.kt │ │ │ │ ├── StyledTextButton.kt │ │ │ │ ├── SystemUi.kt │ │ │ │ ├── TabIndicator.kt │ │ │ │ ├── TabsTopAppBar.kt │ │ │ │ ├── TextString.kt │ │ │ │ ├── TextWithIcon.kt │ │ │ │ ├── Toolbar.kt │ │ │ │ ├── TopAppBar.kt │ │ │ │ ├── TopBarWithTabLayout.kt │ │ │ │ ├── TwoTextsInRow.kt │ │ │ │ ├── VelocityExt.kt │ │ │ │ ├── VerticalIndentLayout.kt │ │ │ │ ├── collapsable/ │ │ │ │ │ ├── CollapsableTopBarLayout.kt │ │ │ │ │ ├── CollapsableTopBarLayoutConnection.kt │ │ │ │ │ └── ScrollUpTopBarLayout.kt │ │ │ │ ├── icons/ │ │ │ │ │ └── Toufu.kt │ │ │ │ ├── image/ │ │ │ │ │ └── viewer/ │ │ │ │ │ ├── ImageViewer.kt │ │ │ │ │ ├── ImageViewerState.kt │ │ │ │ │ └── TransformGestureDetector.kt │ │ │ │ ├── infinite/ │ │ │ │ │ ├── InfiniteBox.kt │ │ │ │ │ └── InfinityBoxState.kt │ │ │ │ ├── inline/ │ │ │ │ │ ├── InlineVideoLazyColumn.kt │ │ │ │ │ └── PlayableIndexRecorderLocal.kt │ │ │ │ ├── pick/ │ │ │ │ │ └── PickVisualMediaLauncherContainer.kt │ │ │ │ ├── sensitive/ │ │ │ │ │ ├── SensitiveLazyColumn.kt │ │ │ │ │ └── SensitiveLazyColumnState.kt │ │ │ │ ├── topout/ │ │ │ │ │ ├── TopOutTopBarLayout.kt │ │ │ │ │ └── TopOutTopBarLayoutConnection.kt │ │ │ │ └── video/ │ │ │ │ └── VideoPlayer.kt │ │ │ ├── controller/ │ │ │ │ ├── CommonLoadableController.kt │ │ │ │ └── LoadableController.kt │ │ │ ├── coroutines/ │ │ │ │ └── JobExt.kt │ │ │ ├── date/ │ │ │ │ ├── DateParser.kt │ │ │ │ └── InstantFormater.kt │ │ │ ├── datetime/ │ │ │ │ └── Instant.kt │ │ │ ├── imageloader/ │ │ │ │ └── ImageLoaderUtils.kt │ │ │ ├── ktx/ │ │ │ │ ├── CollectionsExt.kt │ │ │ │ ├── FlowExt.kt │ │ │ │ ├── LazyBackingFieldDelegate.kt │ │ │ │ ├── StringExt.kt │ │ │ │ └── ViewModels.kt │ │ │ ├── lifecycle/ │ │ │ │ ├── ContainerViewModel.kt │ │ │ │ └── SubViewModel.kt │ │ │ ├── loadable/ │ │ │ │ ├── lazycolumn/ │ │ │ │ │ ├── LoadMoreUi.kt │ │ │ │ │ ├── LoadableInlineVideoLazyColumn.kt │ │ │ │ │ ├── LoadableLazyColumn.kt │ │ │ │ │ └── PullToRefreshIndicator.kt │ │ │ │ └── previous/ │ │ │ │ └── PreviousPageLoadingState.kt │ │ │ ├── module/ │ │ │ │ └── ModuleStartup.kt │ │ │ ├── nav/ │ │ │ │ ├── DialogNavMetadata.kt │ │ │ │ ├── LocalNavBackStack.kt │ │ │ │ ├── NavBackStackExt.kt │ │ │ │ ├── NavEntryProvider.kt │ │ │ │ ├── ScreenEventFlow.kt │ │ │ │ ├── SharedElementScope.kt │ │ │ │ └── Tab.kt │ │ │ ├── network/ │ │ │ │ ├── FormalBaseUrl.kt │ │ │ │ ├── GlobalRoutes.kt │ │ │ │ ├── HttpScheme.kt │ │ │ │ └── SimpleUri.kt │ │ │ ├── opml/ │ │ │ │ ├── OpmlOutline.kt │ │ │ │ └── OpmlParser.kt │ │ │ ├── permission/ │ │ │ │ └── RequireLocalStoragePermission.kt │ │ │ ├── security/ │ │ │ │ └── Md5.kt │ │ │ ├── serialize/ │ │ │ │ └── TimestampAsInstantSerializer.kt │ │ │ ├── toast/ │ │ │ │ └── Toast.kt │ │ │ └── utils/ │ │ │ ├── AspectRatio.kt │ │ │ ├── BlendColorUtils.kt │ │ │ ├── ContentProviderFile.kt │ │ │ ├── DebugUtils.kt │ │ │ ├── DensityUtils.kt │ │ │ ├── DomainValidator.kt │ │ │ ├── DurationFormatUtils.kt │ │ │ ├── ExtractUrlFromTextUtils.kt │ │ │ ├── FloatExt.kt │ │ │ ├── Handle.kt │ │ │ ├── HighlightTextBuildUtil.kt │ │ │ ├── IDNUtils.kt │ │ │ ├── ImageCompressUtils.kt │ │ │ ├── IntExt.kt │ │ │ ├── LanguageUtils.kt │ │ │ ├── LinkPreviewUtils.kt │ │ │ ├── LoadState.kt │ │ │ ├── Log.kt │ │ │ ├── Parcelize.kt │ │ │ ├── PlatformIgnoredOnParcel.kt │ │ │ ├── PlatformParcelable.kt │ │ │ ├── PlatformSerializable.kt │ │ │ ├── PlatformTransient.kt │ │ │ ├── PlatformUri.kt │ │ │ ├── RegexFactory.kt │ │ │ ├── ResultExt.kt │ │ │ ├── Rfc822InstantParser.kt │ │ │ ├── SizeUtils.kt │ │ │ ├── Standard.kt │ │ │ ├── StorageSize.kt │ │ │ ├── TextFieldUtils.kt │ │ │ ├── ThrowableUtils.kt │ │ │ ├── UriUtils.kt │ │ │ ├── UrlEncoder.kt │ │ │ ├── VideoUtils.kt │ │ │ └── WebFinger.kt │ │ └── res/ │ │ └── values/ │ │ ├── colors.xml │ │ └── ids.xml │ ├── commonTest/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── framework/ │ │ ├── network/ │ │ │ └── SimpleUriTest.kt │ │ ├── opml/ │ │ │ └── OpmlParserTest.kt │ │ ├── security/ │ │ │ └── Md5Test.kt │ │ └── utils/ │ │ ├── ExtractUrlFromTextUtilsTest.kt │ │ ├── IntExtTest.kt │ │ └── WebFingerTest.kt │ └── iosMain/ │ └── kotlin/ │ └── com/ │ └── zhangke/ │ └── framework/ │ ├── architect/ │ │ ├── http/ │ │ │ └── HttpClientEngine.ios.kt │ │ └── theme/ │ │ └── FreadTheme.ios.kt │ ├── blurhash/ │ │ └── BitmapExt.ios.kt │ ├── composable/ │ │ ├── TextString.ios.kt │ │ └── pick/ │ │ └── PickVisualMediaLauncherContainer.ios.kt │ ├── date/ │ │ └── InstantFormater.ios.kt │ ├── datetime/ │ │ └── Instant.ios.kt │ ├── permission/ │ │ └── RequireLocalStoragePermission.ios.kt │ ├── toast/ │ │ └── Toast.ios.kt │ └── utils/ │ ├── IDNUtils.ios.kt │ ├── ImageCompressUtils.kt │ ├── LanguageUtils.ios.kt │ ├── PlatformIgnoredOnParcel.ios.kt │ ├── PlatformParcelable.ios.kt │ ├── PlatformTransient.ios.kt │ ├── PlatformUri.ios.kt │ ├── Serializable.ios.kt │ └── VideoUtils.ios.kt ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── iosApp/ │ ├── .gitignore │ ├── Configuration/ │ │ └── Config.xcconfig │ ├── iosApp/ │ │ ├── Assets.xcassets/ │ │ │ ├── AccentColor.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset/ │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── ContentView.swift │ │ ├── GoogleService-Info.plist │ │ ├── Info.plist │ │ ├── Preview Content/ │ │ │ └── Preview Assets.xcassets/ │ │ │ └── Contents.json │ │ └── iOSApp.swift │ └── iosApp.xcodeproj/ │ └── project.pbxproj ├── kotlin-inject-relative.md ├── localization/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── consumer-rules.pro │ ├── proguard-rules.pro │ └── src/ │ ├── androidMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ └── localization/ │ │ └── LanguageCodeUtils.kt │ └── commonMain/ │ ├── composeResources/ │ │ ├── values/ │ │ │ ├── strings.xml │ │ │ ├── strings_mastodon.xml │ │ │ └── strings_status_ui.xml │ │ ├── values-de-rDE/ │ │ │ ├── strings.xml │ │ │ ├── strings_mastodon.xml │ │ │ └── strings_status_ui.xml │ │ ├── values-es-rES/ │ │ │ ├── strings.xml │ │ │ ├── strings_mastodon.xml │ │ │ └── strings_status_ui.xml │ │ ├── values-fr-rFR/ │ │ │ ├── strings.xml │ │ │ ├── strings_mastodon.xml │ │ │ └── strings_status_ui.xml │ │ ├── values-ja-rJP/ │ │ │ ├── strings.xml │ │ │ ├── strings_mastodon.xml │ │ │ └── strings_status_ui.xml │ │ ├── values-pt-rPT/ │ │ │ ├── strings.xml │ │ │ ├── strings_mastodon.xml │ │ │ └── strings_status_ui.xml │ │ ├── values-ru-rRU/ │ │ │ ├── strings.xml │ │ │ ├── strings_mastodon.xml │ │ │ └── strings_status_ui.xml │ │ ├── values-zh-rCN/ │ │ │ ├── strings.xml │ │ │ ├── strings_mastodon.xml │ │ │ └── strings_status_ui.xml │ │ ├── values-zh-rHK/ │ │ │ ├── strings.xml │ │ │ ├── strings_mastodon.xml │ │ │ └── strings_status_ui.xml │ │ └── values-zh-rTW/ │ │ ├── strings.xml │ │ ├── strings_mastodon.xml │ │ └── strings_status_ui.xml │ └── kotlin/ │ └── com/ │ └── zhangke/ │ └── fread/ │ └── localization/ │ ├── LanguageCode.kt │ ├── LanguageCodeNames.kt │ ├── Localization.kt │ └── LocalizedString.kt ├── plugins/ │ ├── activitypub-app/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── consumer-rules.pro │ │ ├── proguard-rules.pro │ │ └── src/ │ │ ├── androidMain/ │ │ │ ├── AndroidManifest.xml │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── zhangke/ │ │ │ └── fread/ │ │ │ └── activitypub/ │ │ │ └── app/ │ │ │ ├── ActivityPubAndroidModule.kt │ │ │ └── internal/ │ │ │ ├── auth/ │ │ │ │ └── ActivityPubOAuthRedirectActivity.kt │ │ │ └── push/ │ │ │ ├── ActivityPubPushManager.android.kt │ │ │ ├── ActivityPubPushMessageReceiverHelper.android.kt │ │ │ ├── CryptoUtil.kt │ │ │ ├── PushInfoRepo.kt │ │ │ └── notification/ │ │ │ ├── ActivityPubPushMessage.kt │ │ │ └── PushNotificationManager.kt │ │ ├── androidUnitTest/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── zhangke/ │ │ │ └── utopia/ │ │ │ └── activitypubapp/ │ │ │ └── ExampleUnitTest.kt │ │ ├── commonMain/ │ │ │ ├── composeResources/ │ │ │ │ └── drawable/ │ │ │ │ └── detail_page_banner_background.xml │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── zhangke/ │ │ │ └── fread/ │ │ │ └── activitypub/ │ │ │ └── app/ │ │ │ ├── ActivityPubAccountManager.kt │ │ │ ├── ActivityPubContentManager.kt │ │ │ ├── ActivityPubJsonBuilder.kt │ │ │ ├── ActivityPubModule.kt │ │ │ ├── ActivityPubNavEntryProvider.kt │ │ │ ├── ActivityPubNotificationResolver.kt │ │ │ ├── ActivityPubProvider.kt │ │ │ ├── ActivityPubPublishManager.kt │ │ │ ├── ActivityPubScreenProvider.kt │ │ │ ├── ActivityPubSearchEngine.kt │ │ │ ├── ActivityPubSourceResolver.kt │ │ │ ├── ActivityPubStartup.kt │ │ │ ├── ActivityPubStatusResolver.kt │ │ │ ├── ActivityPubUrlInterceptor.kt │ │ │ └── internal/ │ │ │ ├── DataTrackingElements.kt │ │ │ ├── adapter/ │ │ │ │ ├── ActivityPubAccountEntityAdapter.kt │ │ │ │ ├── ActivityPubApplicationEntityAdapter.kt │ │ │ │ ├── ActivityPubBlogMetaAdapter.kt │ │ │ │ ├── ActivityPubContentAdapter.kt │ │ │ │ ├── ActivityPubCustomEmojiEntityAdapter.kt │ │ │ │ ├── ActivityPubInstanceAdapter.kt │ │ │ │ ├── ActivityPubLoggedAccountAdapter.kt │ │ │ │ ├── ActivityPubPlatformEntityAdapter.kt │ │ │ │ ├── ActivityPubPollAdapter.kt │ │ │ │ ├── ActivityPubSearchAdapter.kt │ │ │ │ ├── ActivityPubStatusAdapter.kt │ │ │ │ ├── ActivityPubTagAdapter.kt │ │ │ │ ├── ActivityPubTranslationEntityAdapter.kt │ │ │ │ ├── PostStatusAttachmentAdapter.kt │ │ │ │ ├── QuoteApprovalPolicyExt.kt │ │ │ │ ├── RegisterApplicationEntryAdapter.kt │ │ │ │ └── StatusVisibilityExt.kt │ │ │ ├── auth/ │ │ │ │ ├── ActivityPubClientManager.kt │ │ │ │ ├── ActivityPubOAuthor.kt │ │ │ │ └── LoggedAccountProvider.kt │ │ │ ├── composable/ │ │ │ │ └── ActivityPubTabNames.kt │ │ │ ├── content/ │ │ │ │ └── ActivityPubContent.kt │ │ │ ├── db/ │ │ │ │ ├── ActivityPubApplicationTable.kt │ │ │ │ ├── ActivityPubDatabases.kt │ │ │ │ ├── ActivityPubLoggedAccountDatabase.kt │ │ │ │ ├── ActivityPubPlatformTable.kt │ │ │ │ ├── UserIdTable.kt │ │ │ │ ├── converter/ │ │ │ │ │ ├── ActivityPubAccountEntityConverter.kt │ │ │ │ │ ├── ActivityPubInstanceEntityConverter.kt │ │ │ │ │ ├── ActivityPubLoggedAccountConverter.kt │ │ │ │ │ ├── ActivityPubStatusEntityConverter.kt │ │ │ │ │ ├── ActivityPubStatusSourceTypeConverter.kt │ │ │ │ │ ├── ActivityPubUserTokenConverter.kt │ │ │ │ │ ├── BlogAuthorConverter.kt │ │ │ │ │ ├── EmojiListConverter.kt │ │ │ │ │ ├── FormalBaseUrlConverter.kt │ │ │ │ │ ├── PlatformEntityTypeConverter.kt │ │ │ │ │ ├── RelationshipSeveranceEventConverter.kt │ │ │ │ │ └── StatusNotificationTypeConverter.kt │ │ │ │ ├── old/ │ │ │ │ │ └── ActivityPubLoggedAccountTable.kt │ │ │ │ └── status/ │ │ │ │ ├── ActivityPubStatusDatabases.kt │ │ │ │ └── ActivityPubStatusReadStateDatabases.kt │ │ │ ├── migrate/ │ │ │ │ └── ActivityPubContentMigrator.kt │ │ │ ├── model/ │ │ │ │ ├── ActivityPubApplication.kt │ │ │ │ ├── ActivityPubInstanceRule.kt │ │ │ │ ├── ActivityPubLoggedAccount.kt │ │ │ │ ├── ActivityPubStatusSourceType.kt │ │ │ │ ├── ActivityPubTimelineType.kt │ │ │ │ ├── CustomEmoji.kt │ │ │ │ ├── PlatformUriInsights.kt │ │ │ │ ├── RelationshipSeveranceEvent.kt │ │ │ │ ├── ServerDetailContract.kt │ │ │ │ ├── StatusNotification.kt │ │ │ │ ├── StatusNotificationType.kt │ │ │ │ ├── UserSource.kt │ │ │ │ └── UserUriInsights.kt │ │ │ ├── platform/ │ │ │ │ └── FreadApplicationRegisterInfo.kt │ │ │ ├── push/ │ │ │ │ ├── ActivityPubPushManager.kt │ │ │ │ └── ActivityPubPushMessageReceiver.kt │ │ │ ├── repo/ │ │ │ │ ├── WebFingerBaseUrlToUserIdRepo.kt │ │ │ │ ├── account/ │ │ │ │ │ ├── ActivityPubLoggedAccountRepo.kt │ │ │ │ │ └── LoggedAccountMigrateUtil.kt │ │ │ │ ├── application/ │ │ │ │ │ └── ActivityPubApplicationRepo.kt │ │ │ │ ├── platform/ │ │ │ │ │ ├── ActivityPubPlatformRepo.kt │ │ │ │ │ ├── BlogPlatformResourceLoader.kt │ │ │ │ │ └── MastodonInstanceRepo.kt │ │ │ │ ├── status/ │ │ │ │ │ ├── ActivityPubStatusReadStateRepo.kt │ │ │ │ │ └── ActivityPubTimelineStatusRepo.kt │ │ │ │ └── user/ │ │ │ │ └── UserRepo.kt │ │ │ ├── route/ │ │ │ │ └── ActivityPubRoutes.kt │ │ │ ├── screen/ │ │ │ │ ├── account/ │ │ │ │ │ ├── EditAccountInfoScreen.kt │ │ │ │ │ ├── EditAccountInfoViewModel.kt │ │ │ │ │ └── EditAccountUiState.kt │ │ │ │ ├── add/ │ │ │ │ │ ├── AddActivityPubContentScreen.kt │ │ │ │ │ ├── AddActivityPubContentViewModel.kt │ │ │ │ │ └── select/ │ │ │ │ │ ├── SelectPlatformScreen.kt │ │ │ │ │ ├── SelectPlatformUiState.kt │ │ │ │ │ └── SelectPlatformViewModel.kt │ │ │ │ ├── content/ │ │ │ │ │ ├── ActivityPubContentSubViewModel.kt │ │ │ │ │ ├── ActivityPubContentTab.kt │ │ │ │ │ ├── ActivityPubContentUiState.kt │ │ │ │ │ ├── ActivityPubContentViewModel.kt │ │ │ │ │ ├── edit/ │ │ │ │ │ │ ├── EditContentConfigScreen.kt │ │ │ │ │ │ ├── EditContentConfigUiState.kt │ │ │ │ │ │ └── EditContentConfigViewModel.kt │ │ │ │ │ └── timeline/ │ │ │ │ │ ├── ActivityPubTimelineContainerViewModel.kt │ │ │ │ │ ├── ActivityPubTimelineTab.kt │ │ │ │ │ ├── ActivityPubTimelineViewModel.kt │ │ │ │ │ └── ActivityPubUiState.kt │ │ │ │ ├── explorer/ │ │ │ │ │ ├── ExplorerContainerTab.kt │ │ │ │ │ ├── ExplorerContainerViewModel.kt │ │ │ │ │ ├── ExplorerFeedsTabType.kt │ │ │ │ │ ├── ExplorerItem.kt │ │ │ │ │ ├── ExplorerTab.kt │ │ │ │ │ ├── ExplorerUiState.kt │ │ │ │ │ └── ExplorerViewModel.kt │ │ │ │ ├── filters/ │ │ │ │ │ ├── edit/ │ │ │ │ │ │ ├── EditFilterScreen.kt │ │ │ │ │ │ ├── EditFilterUiState.kt │ │ │ │ │ │ ├── EditFilterViewModel.kt │ │ │ │ │ │ ├── FilterContext.kt │ │ │ │ │ │ └── HiddenKeywordScreen.kt │ │ │ │ │ └── list/ │ │ │ │ │ ├── FiltersListScreen.kt │ │ │ │ │ ├── FiltersListUiState.kt │ │ │ │ │ └── FiltersListViewModel.kt │ │ │ │ ├── hashtag/ │ │ │ │ │ ├── HashtagTimelineContainerViewModel.kt │ │ │ │ │ ├── HashtagTimelineScreen.kt │ │ │ │ │ ├── HashtagTimelineUiState.kt │ │ │ │ │ └── HashtagTimelineViewModel.kt │ │ │ │ ├── instance/ │ │ │ │ │ ├── InstanceDetailScreen.kt │ │ │ │ │ ├── InstanceDetailUiState.kt │ │ │ │ │ ├── InstanceDetailViewModel.kt │ │ │ │ │ ├── about/ │ │ │ │ │ │ ├── ServerAboutPage.kt │ │ │ │ │ │ ├── ServerAboutUiState.kt │ │ │ │ │ │ └── ServerAboutViewModel.kt │ │ │ │ │ └── tags/ │ │ │ │ │ ├── ServerTrendsTagsPage.kt │ │ │ │ │ ├── ServerTrendsTagsUiState.kt │ │ │ │ │ └── ServerTrendsTagsViewModel.kt │ │ │ │ ├── list/ │ │ │ │ │ ├── CreatedListsScreen.kt │ │ │ │ │ ├── CreatedListsUiState.kt │ │ │ │ │ ├── CreatedListsViewModel.kt │ │ │ │ │ ├── ListDetailPageContent.kt │ │ │ │ │ ├── ListItem.kt │ │ │ │ │ ├── add/ │ │ │ │ │ │ ├── AddListScreen.kt │ │ │ │ │ │ ├── AddListUiState.kt │ │ │ │ │ │ └── AddListViewModel.kt │ │ │ │ │ └── edit/ │ │ │ │ │ ├── EditListScreen.kt │ │ │ │ │ ├── EditListUiState.kt │ │ │ │ │ └── EditListViewModel.kt │ │ │ │ ├── search/ │ │ │ │ │ ├── SearchStatusScreen.kt │ │ │ │ │ └── SearchStatusViewModel.kt │ │ │ │ ├── status/ │ │ │ │ │ └── post/ │ │ │ │ │ ├── PostStatusScreen.kt │ │ │ │ │ ├── PostStatusScreenRoute.kt │ │ │ │ │ ├── PostStatusUiState.kt │ │ │ │ │ ├── PostStatusViewModel.kt │ │ │ │ │ ├── PublishInteractionSettingLabel.kt │ │ │ │ │ ├── adapter/ │ │ │ │ │ │ └── CustomEmojiAdapter.kt │ │ │ │ │ ├── composable/ │ │ │ │ │ │ ├── CustomEmojiPicker.kt │ │ │ │ │ │ ├── PostStatusBottomBar.kt │ │ │ │ │ │ └── PostStatusPoll.kt │ │ │ │ │ └── usecase/ │ │ │ │ │ ├── GenerateInitPostStatusUiStateUseCase.kt │ │ │ │ │ └── PublishPostUseCase.kt │ │ │ │ ├── trending/ │ │ │ │ │ ├── TrendingStatusSubViewModel.kt │ │ │ │ │ ├── TrendingStatusTab.kt │ │ │ │ │ └── TrendingStatusViewModel.kt │ │ │ │ └── user/ │ │ │ │ ├── UserDetailContainerViewModel.kt │ │ │ │ ├── UserDetailScreen.kt │ │ │ │ ├── UserDetailUiState.kt │ │ │ │ ├── UserDetailViewModel.kt │ │ │ │ ├── list/ │ │ │ │ │ ├── UserListScreen.kt │ │ │ │ │ ├── UserListType.kt │ │ │ │ │ ├── UserListUiState.kt │ │ │ │ │ └── UserListViewModel.kt │ │ │ │ ├── search/ │ │ │ │ │ ├── SearchUserScreen.kt │ │ │ │ │ ├── SearchUserUiState.kt │ │ │ │ │ └── SearchUserViewModel.kt │ │ │ │ ├── status/ │ │ │ │ │ ├── StatusListContainerViewModel.kt │ │ │ │ │ ├── StatusListScreen.kt │ │ │ │ │ ├── StatusListTab.kt │ │ │ │ │ ├── StatusListType.kt │ │ │ │ │ └── StatusListViewModel.kt │ │ │ │ ├── tags/ │ │ │ │ │ ├── TagListScreen.kt │ │ │ │ │ ├── TagListUiState.kt │ │ │ │ │ └── TagListViewModel.kt │ │ │ │ └── timeline/ │ │ │ │ ├── UserTimelineContainerViewModel.kt │ │ │ │ ├── UserTimelineTab.kt │ │ │ │ ├── UserTimelineTabType.kt │ │ │ │ ├── UserTimelineUiState.kt │ │ │ │ └── UserTimelineViewModel.kt │ │ │ ├── source/ │ │ │ │ └── UserSourceTransformer.kt │ │ │ ├── uri/ │ │ │ │ ├── ActivityPubUriHost.kt │ │ │ │ ├── ActivityPubUriPath.kt │ │ │ │ ├── PlatformUriTransformer.kt │ │ │ │ ├── StatusProviderUriExts.kt │ │ │ │ └── UserUriTransformer.kt │ │ │ ├── usecase/ │ │ │ │ ├── ActivityPubAccountLogoutUseCase.kt │ │ │ │ ├── GetDefaultBaseUrlUseCase.kt │ │ │ │ ├── GetInstanceAnnouncementUseCase.kt │ │ │ │ ├── GetServerTrendTagsUseCase.kt │ │ │ │ ├── UpdateActivityPubUserListUseCase.kt │ │ │ │ ├── content/ │ │ │ │ │ ├── GetUserCreatedListUseCase.kt │ │ │ │ │ └── ReorderActivityPubTabUseCase.kt │ │ │ │ ├── emoji/ │ │ │ │ │ ├── GetCustomEmojiUseCase.kt │ │ │ │ │ └── MapCustomEmojiUseCase.kt │ │ │ │ ├── media/ │ │ │ │ │ └── UploadMediaAttachmentUseCase.kt │ │ │ │ ├── platform/ │ │ │ │ │ └── GetInstancePostStatusRulesUseCase.kt │ │ │ │ ├── source/ │ │ │ │ │ └── user/ │ │ │ │ │ └── SearchUserSourceNoTokenUseCase.kt │ │ │ │ └── status/ │ │ │ │ ├── GetStatusContextUseCase.kt │ │ │ │ ├── GetTimelineStatusUseCase.kt │ │ │ │ ├── GetUserStatusUseCase.kt │ │ │ │ ├── StatusInteractiveUseCase.kt │ │ │ │ └── VotePollUseCase.kt │ │ │ └── utils/ │ │ │ ├── Base64Utils.kt │ │ │ ├── DeleteTextUtil.kt │ │ │ ├── MastodonHelper.kt │ │ │ └── PlatformLocatorUtils.kt │ │ └── iosMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ └── activitypub/ │ │ └── app/ │ │ ├── ActivityPubIosModule.kt │ │ └── internal/ │ │ └── push/ │ │ ├── ActivityPubPushManager.ios.kt │ │ └── ActivityPubPushMessageReceiverHelper.ios.kt │ ├── bluesky/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── consumer-rules.pro │ │ ├── proguard-rules.pro │ │ └── src/ │ │ ├── androidMain/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── zhangke/ │ │ │ └── fread/ │ │ │ └── bluesky/ │ │ │ └── BlueskyAndroidModule.kt │ │ ├── androidTest/ │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── zhangke/ │ │ │ └── fread/ │ │ │ └── bluesky/ │ │ │ └── ExampleInstrumentedTest.kt │ │ ├── commonMain/ │ │ │ ├── composeResources/ │ │ │ │ └── drawable/ │ │ │ │ └── bluesky_logo.xml │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── zhangke/ │ │ │ └── fread/ │ │ │ └── bluesky/ │ │ │ ├── BlueskyAccountManager.kt │ │ │ ├── BlueskyModule.kt │ │ │ ├── BlueskyNavEntryProvider.kt │ │ │ ├── BlueskyNotificationResolver.kt │ │ │ ├── BlueskyProvider.kt │ │ │ ├── BlueskyPublishManager.kt │ │ │ ├── BlueskyScreenProvider.kt │ │ │ ├── BlueskySearchEngine.kt │ │ │ ├── BlueskyStatusResolver.kt │ │ │ ├── BlueskyStatusSourceResolver.kt │ │ │ ├── BskyStartup.kt │ │ │ ├── BskyUrlInterceptor.kt │ │ │ └── internal/ │ │ │ ├── account/ │ │ │ │ ├── BlueskyLoggedAccount.kt │ │ │ │ └── BlueskyLoggedAccountManager.kt │ │ │ ├── adapter/ │ │ │ │ ├── BlueskyAccountAdapter.kt │ │ │ │ ├── BlueskyFeedsAdapter.kt │ │ │ │ ├── BlueskyNotificationAdapter.kt │ │ │ │ ├── BlueskyProfileAdapter.kt │ │ │ │ └── BlueskyStatusAdapter.kt │ │ │ ├── client/ │ │ │ │ ├── BlueskyClient.kt │ │ │ │ ├── BlueskyClientManager.kt │ │ │ │ ├── BlueskyResponseUtils.kt │ │ │ │ ├── BskyCollections.kt │ │ │ │ ├── BskyHttpPlugin.kt │ │ │ │ ├── records.kt │ │ │ │ └── rkeys.kt │ │ │ ├── composable/ │ │ │ │ ├── BlueskyFeedsUi.kt │ │ │ │ └── DetailTopBar.kt │ │ │ ├── content/ │ │ │ │ ├── BlueskyContent.kt │ │ │ │ └── BlueskyContentManager.kt │ │ │ ├── db/ │ │ │ │ ├── BlueskyLoggedAccountDatabase.kt │ │ │ │ └── converter/ │ │ │ │ ├── BlueskyLoggedAccountConverter.kt │ │ │ │ └── GeneratorViewConverter.kt │ │ │ ├── migrate/ │ │ │ │ └── BlueskyContentMigrator.kt │ │ │ ├── model/ │ │ │ │ ├── BlueskyFeeds.kt │ │ │ │ ├── BlueskyProfile.kt │ │ │ │ ├── BskyPagingFeeds.kt │ │ │ │ └── CompletedBskyNotification.kt │ │ │ ├── repo/ │ │ │ │ ├── BlueskyLoggedAccountRepo.kt │ │ │ │ └── BlueskyPlatformRepo.kt │ │ │ ├── screen/ │ │ │ │ ├── BlueskyRoutes.kt │ │ │ │ ├── add/ │ │ │ │ │ ├── AddBlueskyContentScreen.kt │ │ │ │ │ ├── AddBlueskyContentUiState.kt │ │ │ │ │ └── AddBlueskyContentViewModel.kt │ │ │ │ ├── content/ │ │ │ │ │ ├── BlueskyContentContainerViewModel.kt │ │ │ │ │ ├── BlueskyContentTab.kt │ │ │ │ │ ├── BlueskyContentUiState.kt │ │ │ │ │ └── BlueskyContentViewModel.kt │ │ │ │ ├── explorer/ │ │ │ │ │ └── BlueskyExplorerTab.kt │ │ │ │ ├── feeds/ │ │ │ │ │ ├── detail/ │ │ │ │ │ │ ├── FeedsDetailScreen.kt │ │ │ │ │ │ ├── FeedsDetailUiState.kt │ │ │ │ │ │ └── FeedsDetailViewModel.kt │ │ │ │ │ ├── explorer/ │ │ │ │ │ │ ├── ExplorerFeedsScreen.kt │ │ │ │ │ │ ├── ExplorerFeedsUiState.kt │ │ │ │ │ │ └── ExplorerFeedsViewModel.kt │ │ │ │ │ ├── following/ │ │ │ │ │ │ ├── BskyFollowingFeedsPage.kt │ │ │ │ │ │ ├── BskyFollowingFeedsUiState.kt │ │ │ │ │ │ └── BskyFollowingFeedsViewModel.kt │ │ │ │ │ └── home/ │ │ │ │ │ ├── HomeFeedsContainerViewModel.kt │ │ │ │ │ ├── HomeFeedsScreen.kt │ │ │ │ │ ├── HomeFeedsTab.kt │ │ │ │ │ └── HomeFeedsViewModel.kt │ │ │ │ ├── publish/ │ │ │ │ │ ├── MentionCandidateBar.kt │ │ │ │ │ ├── PublishPostEmbed.kt │ │ │ │ │ ├── PublishPostMediaAttachment.kt │ │ │ │ │ ├── PublishPostScreen.kt │ │ │ │ │ ├── PublishPostUiState.kt │ │ │ │ │ └── PublishPostViewModel.kt │ │ │ │ ├── search/ │ │ │ │ │ ├── SearchStatusScreen.kt │ │ │ │ │ └── SearchStatusViewModel.kt │ │ │ │ └── user/ │ │ │ │ ├── detail/ │ │ │ │ │ ├── BskyUserDetailScreen.kt │ │ │ │ │ ├── BskyUserDetailUiState.kt │ │ │ │ │ └── BskyUserDetailViewModel.kt │ │ │ │ ├── edit/ │ │ │ │ │ ├── EditProfileScreen.kt │ │ │ │ │ ├── EditProfileUiState.kt │ │ │ │ │ └── EditProfileViewModel.kt │ │ │ │ └── list/ │ │ │ │ ├── UserListItemUiState.kt │ │ │ │ ├── UserListScreen.kt │ │ │ │ ├── UserListType.kt │ │ │ │ └── UserListViewModel.kt │ │ │ ├── tracking/ │ │ │ │ └── BskyTrackingElements.kt │ │ │ ├── uri/ │ │ │ │ ├── BlueskyUriHost.kt │ │ │ │ ├── BlueskyUriPath.kt │ │ │ │ ├── platform/ │ │ │ │ │ ├── PlatformUriInsights.kt │ │ │ │ │ └── PlatformUriTransformer.kt │ │ │ │ └── user/ │ │ │ │ ├── UserUriInsights.kt │ │ │ │ └── UserUriTransformer.kt │ │ │ ├── usecase/ │ │ │ │ ├── BskyStatusInteractiveUseCase.kt │ │ │ │ ├── CreateRecordUseCase.kt │ │ │ │ ├── DeleteRecordUseCase.kt │ │ │ │ ├── GetAllListsUseCase.kt │ │ │ │ ├── GetAtIdentifierUseCase.kt │ │ │ │ ├── GetCompletedNotificationUseCase.kt │ │ │ │ ├── GetFeedsStatusUseCase.kt │ │ │ │ ├── GetFollowingFeedsUseCase.kt │ │ │ │ ├── GetStatusContextUseCase.kt │ │ │ │ ├── LoginToBskyUseCase.kt │ │ │ │ ├── PinFeedsUseCase.kt │ │ │ │ ├── PublishingPostUseCase.kt │ │ │ │ ├── RefreshSessionUseCase.kt │ │ │ │ ├── UnblockUserWithoutUriUseCase.kt │ │ │ │ ├── UnpinFeedsUseCase.kt │ │ │ │ ├── UpdateBlockUseCase.kt │ │ │ │ ├── UpdateHomeTabUseCase.kt │ │ │ │ ├── UpdatePinnedFeedsOrderUseCase.kt │ │ │ │ ├── UpdatePreferencesUseCase.kt │ │ │ │ ├── UpdateProfileRecordUseCase.kt │ │ │ │ ├── UpdateRelationshipUseCase.kt │ │ │ │ ├── UploadBlobUseCase.kt │ │ │ │ └── UploadImageByImageUrlUseCase.kt │ │ │ └── utils/ │ │ │ ├── AtResponseUtils.kt │ │ │ ├── PlatformLocatorUtils.kt │ │ │ └── Tid.kt │ │ ├── iosMain/ │ │ │ └── kotlin/ │ │ │ └── com/ │ │ │ └── zhangke/ │ │ │ └── fread/ │ │ │ └── bluesky/ │ │ │ └── BlueskyIosModule.kt │ │ └── test/ │ │ └── java/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ └── bluesky/ │ │ └── ExampleUnitTest.kt │ └── rss/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── consumer-rules.pro │ ├── proguard-rules.pro │ └── src/ │ ├── androidMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ └── rss/ │ │ ├── RssAndroidModule.kt │ │ └── internal/ │ │ └── utils/ │ │ └── AvatarUtils.android.kt │ ├── commonMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ └── rss/ │ │ ├── RssAccountManager.kt │ │ ├── RssContentManager.kt │ │ ├── RssModule.kt │ │ ├── RssNavEntryProvider.kt │ │ ├── RssNotificationResolver.kt │ │ ├── RssPublishManager.kt │ │ ├── RssScreenProvider.kt │ │ ├── RssSearchEngine.kt │ │ ├── RssStatusProvider.kt │ │ ├── RssStatusResolver.kt │ │ ├── RssStatusSourceResolver.kt │ │ └── internal/ │ │ ├── adapter/ │ │ │ ├── BlogAuthorAdapter.kt │ │ │ ├── RssBlogMediaExtractor.kt │ │ │ └── RssStatusAdapter.kt │ │ ├── db/ │ │ │ ├── RssChannelTable.kt │ │ │ ├── RssDatabases.kt │ │ │ └── converter/ │ │ │ ├── FormalUriConverter.kt │ │ │ └── InstantConverter.kt │ │ ├── model/ │ │ │ ├── RssChannelItem.kt │ │ │ └── RssSource.kt │ │ ├── platform/ │ │ │ └── RssPlatformTransformer.kt │ │ ├── repo/ │ │ │ ├── RssRepo.kt │ │ │ └── RssStatusRepo.kt │ │ ├── rss/ │ │ │ ├── RssChannel.kt │ │ │ ├── RssFetcher.kt │ │ │ ├── RssImage.kt │ │ │ ├── RssItem.kt │ │ │ ├── RssParserWrapper.kt │ │ │ └── adapter/ │ │ │ ├── RssChannelAdapter.kt │ │ │ ├── RssImageAdapter.kt │ │ │ └── RssItemAdapter.kt │ │ ├── screen/ │ │ │ ├── RssRoutes.kt │ │ │ └── source/ │ │ │ ├── RssSourceScreen.kt │ │ │ ├── RssSourceUiState.kt │ │ │ └── RssSourceViewModel.kt │ │ ├── source/ │ │ │ └── RssSourceTransformer.kt │ │ ├── uri/ │ │ │ ├── RssUri.kt │ │ │ ├── RssUriHost.kt │ │ │ ├── RssUriInsight.kt │ │ │ ├── RssUriPath.kt │ │ │ └── RssUriTransformer.kt │ │ ├── utils/ │ │ │ └── AvatarUtils.kt │ │ └── webfinger/ │ │ └── RssSourceWebFingerTransformer.kt │ ├── commonTest/ │ │ └── kotlin/ │ │ └── com/ │ │ └── zhangke/ │ │ └── fread/ │ │ └── rss/ │ │ └── internal/ │ │ └── adapter/ │ │ └── RssBlogMediaExtractorTest.kt │ └── iosMain/ │ └── kotlin/ │ └── com/ │ └── zhangke/ │ └── fread/ │ └── rss/ │ ├── RssIosModule.kt │ └── internal/ │ └── utils/ │ └── AvatarUtils.ios.kt ├── privacy_cn.md ├── privacy_en.md ├── settings.gradle.kts └── thirds/ ├── halilibo-richtext-material3/ │ ├── build.gradle.kts │ └── src/ │ ├── androidMain/ │ │ └── AndroidManifest.xml │ └── commonMain/ │ └── kotlin/ │ └── com/ │ └── halilibo/ │ └── richtext/ │ └── ui/ │ └── material3/ │ └── RichText.kt └── halilibo-richtext-ui/ ├── build.gradle.kts └── src/ ├── androidMain/ │ ├── AndroidManifest.xml │ └── kotlin/ │ └── com/ │ └── halilibo/ │ └── richtext/ │ └── ui/ │ ├── CodeBlock.android.kt │ └── util/ │ └── UUID.android.kt ├── commonMain/ │ └── kotlin/ │ └── com/ │ └── halilibo/ │ └── richtext/ │ └── ui/ │ ├── BasicRichText.kt │ ├── BlockQuote.kt │ ├── CodeBlock.kt │ ├── FormattedList.kt │ ├── Heading.kt │ ├── HorizontalRule.kt │ ├── InfoPanel.kt │ ├── RichTextLocals.kt │ ├── RichTextScope.kt │ ├── RichTextStyle.kt │ ├── RichTextThemeConfiguration.kt │ ├── RichTextThemeProvider.kt │ ├── SimpleTableLayout.kt │ ├── Table.kt │ ├── string/ │ │ ├── InlineContent.kt │ │ ├── RichTextString.kt │ │ └── Text.kt │ └── util/ │ ├── ConditionalTapGestureDetector.kt │ └── UUID.kt └── iosMain/ └── kotlin/ └── com/ └── halilibo/ └── richtext/ └── ui/ ├── CodeBlock.ios.kt └── util/ └── UUID.ios.kt ================================================ FILE CONTENTS ================================================ ================================================ FILE: .codex/skills/screen2navkey/SKILL.md ================================================ --- name: screen2navkey description: convert Voyager Screen to navigation 3 --- ## 任务背景 目前项目中使用了 Voyager(cafe.adriel.voyager:voyager-navigator) 作为导航框架,现在我希望将 Voyager 替换成 navigation3(androidx.navigation3:navigation3-runtime). ## 任务内容 目前 nav3 我已经集成并且完成了部分代码的重构,现在你需要帮我做一件事情,将一些 Screen 替换成 一个 Composable 函数 + NavKey。 你的目的是把继承自 cafe.adriel.voyager.core.screen.Screen 或者 com.zhangke.fread.common.page.BaseScreen 的类改成 androix.navigaton3 的一个 NavKey + 对应的 Composable 函数。 比如现在有这样的一个 Screen: ```kotlin class ProfileScreen : BaseScreen() { @Composable override fun Content() { super.Content() val viewModel = getViewModel() Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colors.background), ) { Text(text = "Profile Screen") } } } ``` 那么你需要改成如下方式,并且新增一个 NavKey: ```kotlin object ProfileScreenKey: NavKey @Composable fun ProfileScreen(viewModel: ProfileHomeViewModel){ Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colors.background), ) { Text(text = "Profile Screen") } } ``` 但如果这个页面有参数,那么 key 也应该带一个参数: ```kotlin data class DetailScreenKey(val itemId: String) : NavKey @Composable fun DetailScreen(viewModel: DetailViewModel){ Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colors.background), ) { Text(text = "Detail Screen for item: $itemId") } } ``` 然后你需要把这个新增的 NavKey 注册到当前模块的 NavEntryProvider 中,比如: ```kotlin class ProfileNavEntryProvider : NavEntryProvider { override fun EntryProviderScope.build() { entry { ProfileScreen(koinViewModel()) } entry { key -> // with parameters CreatePlanScreen(koinViewModel { parametersOf(key.lexicon) }) } } override fun PolymorphicModuleBuilder.polymorph() { subclass(ProfileScreenKey::class) subclass(CreatePlanScreenNavKey::class) } } ``` ## 工作流程 你需要 Follow 以下工作流程: 1. 首先找到给定模块中所有符合如下条件的 Screen: a. 继承自 cafe.adriel.voyager.core.screen.Screen 或者 com.zhangke.fread.common.page.BaseScreen b. 不包含任何嵌套 **Navigator** 2. 将这些符合条件的 Screen 列出并输出到控制台 3. 逐个重构这些 Screen 4. 对于每个 Screen,首先创建该 Screen 的 NavKey,比如给 ProfileScreen 创建一个 ProfileScreenNavKey. 5. 将 ProfileScreen 改为 @Composable 函数。 6. 对于使用了 navigationResult 的地方请保持不动,不要试图修改相关的代码,即使有编译报错也不用管,保留原样。 7. 将 ProfileScreenNavKey 以及这个 @Composable 函数 注册到该模块的 NavEntryProvider 中。 8. 找到这个 Screen 的相关引用,并将跳转处改为这个 Screen 的 NavKey 9. 结束这个 Screen 重构并进入下一个 Screen。 10. 直到所有重构完所有满足条件的 Screen。 ## 绝对禁止 一下内容为绝对禁止修改的规则: 1. 对于已经修改完成的类请不要再改 2. 你只应该修改 Screen 和 navigation3 相关的代码,其他的代码不要改,即使你觉得有问题也不要改 3. 不要做任何超出我要求的事情 4. 遇到不属于上述情况的页面请直接忽略,不要自己想办法解决 5. 不要求改任何嵌套的 Navigator 页面,遇到嵌套的情况直接跳过 6. 不要修改任何已经使用 navigation3 的页面 7. 不要通过代码引用的方式找某个页面的引用并且试图修改其引用点 8. 不要修改任何超出要求的代码 ================================================ FILE: .codex/skills/voyager2nav3/SKILL.md ================================================ --- name: voyager2nav3 description: convert Voyager Screen to navigation 3 --- 目前项目中使用了 Voyager(cafe.adriel.voyager:voyager-navigator) 作为导航框架,现在我希望将 Voyager 替换成 navigation3(androidx.navigation3:navigation3-runtime). nav3我已经集成并且完成了部分代码,现在你需要帮我做一件事情,将所有的 Screen 替换成 一个 Composable 函数。 抽象类不要改, 只需要把继承自 cafe.adriel.voyager.core.screen.Screen 或者 com.zhangke.fread.common.page.BaseScreen 的类改成 Composable 函数即可。 比如现在有这样的一个 Screen: ```kotlin class ProfileScreen : BaseScreen() { @Composable override fun Content() { super.Content() val viewModel = getViewModel() Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colors.background), ) { Text(text = "Profile Screen") } } } ``` 那么你需要改成如下方式,并且新增一个 NavKey: ```kotlin object ProfileScreenKey: NavKey @Composable fun ProfileScreen(viewModel: ProfileHomeViewModel){ Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colors.background), ) { Text(text = "Profile Screen") } } ``` 但如果这个页面有参数,那么 key 也应该带一个参数: ```kotlin data class DetailScreenKey(val itemId: String) : NavKey @Composable fun DetailScreen(viewModel: DetailViewModel){ Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colors.background), ) { Text(text = "Detail Screen for item: $itemId") } } ``` 然后你需要把这个新增的 NavKey 注册到当前模块的 NavEntryProvider 中,比如: ```kotlin class ProfileNavEntryProvider : NavEntryProvider { override fun EntryProviderScope.build() { entry { ProfileScreen(koinViewModel()) } entry { key -> // with parameters CreatePlanScreen(koinViewModel { parametersOf(key.lexicon) }) } } override fun PolymorphicModuleBuilder.polymorph() { subclass(ProfileScreenKey::class) subclass(CreatePlanScreenNavKey::class) } } ``` 然后跳转该页面的地方也需要同步修改,首先替换 LocalNavigator.currentOrThrow 为 LocalNavBackStack.currentOrThrow,然后通过 NavKey 跳转页面。 你需要按照模块来完成工作。 对于已经修改完成的类请不要再改。 你只应该修改 Screen 和 navigation3 相关的代码,其他的代码不要改,即使你觉得有问题也不要改。 不要修改任何 Tab 以及其直接引用的页面。 遇到不属于上述情况的页面请直接忽略,不要自己想办法解决。 不要求改任何嵌套的 Navigator 页面,遇到嵌套的情况直接跳过。 不要做任何超出我要求的事情。 ================================================ FILE: .editorconfig ================================================ [*.kt] max_line_length = 100 [*.java] max_line_length = 100 ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username e.g., user1 open_collective: # Replace with a single Open Collective username e.g., user1 ko_fi: zhangke tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel polar: # Replace with a single Polar username e.g., user1 buy_me_a_coffee: # Replace with a single Buy Me a Coffee username e.g., user1 thanks_dev: # Replace with a single thanks.dev username e.g., u/gh/user1 community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username e.g., user1 issuehunt: # Replace with a single IssueHunt username e.g., user1 otechie: # Replace with a single Otechie username e.g., user1 custom: ['https://afdian.com/a/_0cdc1'] ================================================ FILE: .github/ci-gradle.properties ================================================ org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=2g -Dfile.encoding=UTF-8 org.gradle.daemon=false org.gradle.parallel=true org.gradle.workers.max=2 # kotlin kotlin.compiler.execution.strategy=in-process kotlin.native.ignoreDisabledTargets=true # other warningsAsErrors=false ================================================ FILE: .github/workflows/build_apk.yml ================================================ on: push: tags: - '**' concurrency: group: ${{ github.event.pull_request.number }}-build-ci cancel-in-progress: true permissions: contents: write jobs: build: runs-on: ubuntu-latest steps: - name: Checkout repository and submodules uses: actions/checkout@v4 with: submodules: recursive - name: Validate Gradle Wrapper uses: gradle/wrapper-validation-action@v3 - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: Set up JDK uses: actions/setup-java@v3 with: distribution: 'zulu' java-version: 17 - name: Decode keystore run: printf "%s" "${{ secrets.FREADKEYSTORE }}" | base64 --decode > fread-keystore.jks - name: Create keystore.properties run: | cat > keystore.properties < fread-keystore.jks - name: Create keystore.properties run: | cat > keystore.properties <

Fread

Fread is a decentralized microblogging client that seamlessly integrates Mastodon, Bluesky, and RSS — all in one place.

- `fread-xxx-google-play-signed.apk`: Signed by Google Play and includes push notifications. If you are used to updating through Google Play, you can choose this version. - `fread-xxx-fdroid.apk`: The version available on F-Droid does not include push notifications, and its signature is inconsistent with Google Play. If you are used to using F-Droid, please use this version. - `fread-xxx-debug.apk`: Test version, contains some logs, the functionality is the same as the Google Play version, but the signature is the same as F-Droid. Because Fread accidentally used Google Play managed signatures, there are currently two different signed versions of Fread distributed. **These two versions cannot be upgraded to each other**. Please choose with caution. ## Screenshots ![screenshot](/screenshot/screenshot.jpg) ## Build The signing key is required, or you can simply delete the signing configuration in app/build.gradle to complete the compilation. ### Build disable firebase Manually disable Firebase join compilation ``` ./gradlew assembleRelease -PdisableFirebase=true ``` ## Blogs - [Why Open Source](https://medium.com/@kezhang404/after-two-years-of-development-the-fread-project-is-now-open-source-8adcf690bfac) - [Support Bluesky](https://medium.com/@kezhang404/fread-now-supports-bluesky-a-unified-gateway-to-the-decentralized-web-17f518ba877c) - [Fread Introduce](https://medium.com/@kezhang404/fread-the-next-generation-mastodon-client-30bc50e279fd) - [The Philosophy of Modular Division in a Large Android Project ](https://medium.com/@kezhang404/the-philosophy-of-modular-division-in-a-large-android-project-e588a5dcdb78) - [Fread Features and Midterm Review ](https://medium.com/p/fread-features-and-midterm-review-c961e1a8930f) [**Deepwiki**](https://deepwiki.com/0xZhangKe/Fread) ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=0xZhangKe/Fread&type=Date)](https://www.star-history.com/#0xZhangKe/Fread&Date) ## Discussion Group - [Telegram](https://t.me/+-SlbKcNbJSphNWI1) ## Official Account - [Mastodon](https://mastodon.social/@fread) ## Donate [Ko-Fi](https://ko-fi.com/zhangke) or [Afdian](https://afdian.com/a/_0cdc1) ### Sponsors Thanks to the following users for their support:

user1     user3

================================================ FILE: app/.gitignore ================================================ /build /debug /release /google-services.json ================================================ FILE: app/build.gradle.kts ================================================ import java.io.FileInputStream import java.util.Properties plugins { id("fread.android.application") id("fread.compose.multiplatform") id("com.google.devtools.ksp") } val keystorePropertiesFile: File = rootProject.file("keystore.properties") val keystoreProperties = Properties() keystoreProperties.load(FileInputStream(keystorePropertiesFile)) android { namespace = "com.zhangke.fread" buildFeatures { buildConfig = true } dependenciesInfo { includeInApk = false includeInBundle = false } signingConfigs { getByName("debug") { storeFile = file(keystoreProperties.getProperty("storeFile")) keyPassword = keystoreProperties.getProperty("keyPassword") storePassword = keystoreProperties.getProperty("storePassword") keyAlias = keystoreProperties.getProperty("keyAlias") } create("release") { storeFile = file(keystoreProperties.getProperty("storeFile")) keyPassword = keystoreProperties.getProperty("keyPassword") storePassword = keystoreProperties.getProperty("storePassword") keyAlias = keystoreProperties.getProperty("keyAlias") } } defaultConfig { applicationId = "com.zhangke.fread" versionCode = 108010 versionName = "1.8.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { isMinifyEnabled = true proguardFiles("proguard-rules.pro") } debug { isMinifyEnabled = false proguardFiles("proguard-rules.pro") } getByName("release") { signingConfig = signingConfigs.getByName("release") } getByName("debug") { signingConfig = signingConfigs.getByName("debug") } } bundle { language { enableSplit = false } } } dependencies { implementation(project(path = ":framework")) implementation(project(path = ":bizframework:status-provider")) implementation(project(path = ":commonbiz:common")) implementation(project(path = ":commonbiz:analytics")) implementation(project(path = ":commonbiz:status-ui")) implementation(project(path = ":commonbiz:sharedscreen")) implementation(project(path = ":feature:feeds")) implementation(project(path = ":feature:explore")) implementation(project(path = ":feature:profile")) implementation(project(path = ":feature:notifications")) implementation(project(path = ":plugins:activitypub-app")) implementation(project(path = ":plugins:rss")) implementation(project(path = ":plugins:bluesky")) if (gradle.extra["enableFirebaseModule"] == true) { implementation(project(path = ":plugins:fread-firebase")) } implementation(project(path = ":app-hosting")) implementation(compose.material3) implementation(compose.components.resources) implementation(libs.bundles.androidx.activity) implementation(libs.imageLoader) implementation(libs.krouter.runtime) implementation(libs.bundles.androidx.media3) implementation(libs.androidx.appcompat) implementation(libs.bundles.androidx.nav3) // ksp(libs.krouter.reducing.compiler) implementation(libs.koin.core) implementation(libs.koin.android) } listOf("assembleRelease", "assembleDebug").forEach { taskName -> tasks.whenTaskAdded { if (name == taskName) { doLast { val buildType = if (taskName.contains("Release")) "release" else "debug" val apkDir = layout.buildDirectory.dir("outputs/apk/$buildType").get().asFile val apkFile = apkDir.listFiles()?.firstOrNull { it.name.endsWith(".apk") } if (apkFile != null) { val versionCode = android.defaultConfig.versionCode val enableFirebaseModule = gradle.extra["enableFirebaseModule"] == true val renamedApkName = if (enableFirebaseModule) { "fread-$versionCode-$buildType.apk" } else { "fread-$versionCode-fdroid.apk" } val newFile = File(apkDir, renamedApkName) apkFile.renameTo(newFile) println(">>> APK renamed to ${newFile.absolutePath}") } } } } } ================================================ FILE: app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable -dontobfuscate -keepattributes * -dontwarn androidx.test.platform.app.InstrumentationRegistry -keepnames class * implements java.io.Serializable -keepclassmembers class * implements java.io.Serializable { static final long serialVersionUID; private static final java.io.ObjectStreamField[] serialPersistentFields; !static !transient ; private void writeObject(java.io.ObjectOutputStream); private void readObject(java.io.ObjectInputStream); java.lang.Object writeReplace(); java.lang.Object readResolve(); } -keep class * implements android.os.Parcelable { public static final android.os.Parcelable$Creator *; } -keep enum androidx.compose.material3.SheetValue { *; } -keepclassmembers class **.R$* { public static ; } -keep class * extends androidx.room.RoomDatabase -dontwarn androidx.room.paging.** -dontwarn androidx.lifecycle.LiveData -keep @androidx.room.Entity class * { *; } # Ktor -dontwarn org.slf4j.impl.StaticLoggerBinder -dontwarn io.ktor.client.network.sockets.TimeoutExceptionsCommonKt -dontwarn io.ktor.client.plugins.HttpTimeout$HttpTimeoutCapabilityConfiguration -dontwarn io.ktor.client.plugins.HttpTimeout$Plugin -dontwarn io.ktor.client.plugins.HttpTimeout -dontwarn io.ktor.util.InternalAPI -dontwarn io.ktor.utils.io.ByteReadChannelJVMKt -dontwarn io.ktor.utils.io.CoroutinesKt -dontwarn io.ktor.utils.io.ReadSessionKt -dontwarn io.ktor.utils.io.core.Buffer$Companion -dontwarn io.ktor.utils.io.core.Buffer -dontwarn io.ktor.utils.io.core.ByteBuffersKt -dontwarn io.ktor.utils.io.core.BytePacketBuilder -dontwarn io.ktor.utils.io.core.ByteReadPacket$Companion -dontwarn io.ktor.utils.io.core.ByteReadPacket -dontwarn io.ktor.utils.io.core.CloseableJVMKt -dontwarn io.ktor.utils.io.core.Input -dontwarn io.ktor.utils.io.core.InputArraysKt -dontwarn io.ktor.utils.io.core.InputPrimitivesKt -dontwarn io.ktor.utils.io.core.Output -dontwarn io.ktor.utils.io.core.OutputPrimitivesKt -dontwarn io.ktor.utils.io.core.PreviewKt -dontwarn io.ktor.client.network.sockets.SocketTimeoutException -dontwarn androidx.window.extensions.area.ExtensionWindowAreaPresentation -dontwarn androidx.window.extensions.core.util.function.Consumer -dontwarn androidx.window.extensions.core.util.function.Function -dontwarn androidx.window.extensions.core.util.function.Predicate # Parcelable -keep class * implements android.os.Parcelable { public static final android.os.Parcelable$Creator *; } -keep class com.zhangke.activitypub.entities.** { *; } -keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept. static <1>$$serializer INSTANCE; } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/java/com/zhangke/fread/FreadAndroidApplication.kt ================================================ package com.zhangke.fread /** * Created by ZhangKe on 2022/11/27. */ class FreadAndroidApplication : HostingApplication() ================================================ FILE: app/src/main/java/com/zhangke/fread/screen/FreadActivity.kt ================================================ package com.zhangke.fread.screen import android.Manifest import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material3.ColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import com.zhangke.framework.activity.TopActivityManager import com.zhangke.framework.architect.theme.FreadTheme import com.zhangke.fread.common.config.FreadConfigManager import com.zhangke.fread.common.daynight.DayNightHelper import com.zhangke.fread.common.deeplink.ExternalInputHandler import com.zhangke.fread.common.theme.ThemeType import com.zhangke.fread.common.utils.ActivityResultCallback import com.zhangke.fread.common.utils.CallbackableActivity import com.zhangke.fread.status.StatusProvider import kotlinx.coroutines.launch import org.koin.android.ext.android.inject class FreadActivity : ComponentActivity(), CallbackableActivity { private val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission(), ) { if (it) { subscribeNotification() } } private val callbacks = mutableMapOf() private val dayNightHelper by inject() private val freadConfigManager by inject() private val statusProvider by inject() override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) handleIntent(intent) } override fun onCreate(savedInstanceState: Bundle?) { dayNightHelper.setDefaultMode() enableEdgeToEdge() super.onCreate(savedInstanceState) initNotification() intent?.let(::handleIntent) setContent { val themeType by freadConfigManager.themeTypeFlow.collectAsState() val dayNightMode by dayNightHelper.dayNightModeFlow.collectAsState() val amoledMode by dayNightHelper.amoledModeFlow.collectAsState() val darkTheme = dayNightMode.isNight FreadTheme( darkTheme = darkTheme, amoledMode = amoledMode, dynamicColors = getDynamicColorScheme(darkTheme, themeType), ) { FreadApp() } } } private fun getDynamicColorScheme( dark: Boolean, themeType: ThemeType, ): ColorScheme? { if (themeType != ThemeType.SYSTEM_DYNAMIC) return null return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (dark) { dynamicDarkColorScheme(this) } else { dynamicLightColorScheme(this) } } else { null } } private fun initNotification() { if (checkNotificationPermission()) { subscribeNotification() } } private fun subscribeNotification() { lifecycleScope.launch { statusProvider.accountManager.subscribeNotification() } } private fun checkNotificationPermission(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true val selfPermissionState = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) if (selfPermissionState == PackageManager.PERMISSION_GRANTED) return true // if (shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) return false requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) return false } private fun handleIntent(intent: Intent) { Log.d("Z_TEST", "intent: ${intent.dataString}") lifecycleScope.launch { if (intent.action == Intent.ACTION_SEND) { if (intent.type?.startsWith("text/") == true) { intent.getStringExtra(Intent.EXTRA_TEXT) ?.takeIf { it.isNotEmpty() } ?.let { ExternalInputHandler.handle(it) } } } else { intent.data?.toString() ?.takeIf { it.isNotEmpty() } ?.let { ExternalInputHandler.handle(it) } } setIntent(Intent()) } } override fun registerCallback(requestCode: Int, callback: ActivityResultCallback) { callbacks[requestCode] = callback } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) TopActivityManager.updateTopActivity(this) callbacks[requestCode]?.invoke(resultCode, data) } } ================================================ FILE: app/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_alert_dialog_background.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: app/src/main/res/values/colors.xml ================================================ #FFBB86FC #FF6200EE #FF3700B3 #FF03DAC5 #FF018786 #FF000000 #FFFFFFFF ================================================ FILE: app/src/main/res/values/ic_launcher_background.xml ================================================ #B2E1FF ================================================ FILE: app/src/main/res/values/strings.xml ================================================ Fread ================================================ FILE: app/src/main/res/values/themes.xml ================================================ ================================================ FILE: commonbiz/common/src/commonMain/composeResources/drawable/bluesky_logo.xml ================================================ ================================================ FILE: commonbiz/common/src/commonMain/composeResources/drawable/ic_explorer.xml ================================================ ================================================ FILE: commonbiz/common/src/commonMain/composeResources/drawable/ic_not_found_404.xml ================================================ ================================================ FILE: commonbiz/common/src/commonMain/composeResources/drawable/mastodon_black_text.xml ================================================ ================================================ FILE: commonbiz/common/src/commonMain/composeResources/drawable/mastodon_logo.xml ================================================ ================================================ FILE: commonbiz/common/src/commonMain/composeResources/drawable/mastodon_white_text.xml ================================================ ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/CommonModule.kt ================================================ package com.zhangke.fread.common import com.zhangke.framework.architect.coroutines.ApplicationScope import com.zhangke.framework.module.ModuleStartup import com.zhangke.framework.nav.NavEntryProvider import com.zhangke.fread.common.account.ActiveAccountsSynchronizer import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.browser.BrowserLauncher import com.zhangke.fread.common.browser.UrlRedirectViewModel import com.zhangke.fread.common.bubble.BubbleManager import com.zhangke.fread.common.config.FreadConfigManager import com.zhangke.fread.common.config.LocalConfigManager import com.zhangke.fread.common.content.FreadContentDbMigrateManager import com.zhangke.fread.common.content.FreadContentRepo import com.zhangke.fread.common.daynight.DayNightHelper import com.zhangke.fread.common.deeplink.SelectAccountForPublishViewModel import com.zhangke.fread.common.deeplink.SelectedContentSwitcher import com.zhangke.fread.common.di.ApplicationCoroutineScope import com.zhangke.fread.common.mixed.MixedStatusRepo import com.zhangke.fread.common.onboarding.OnboardingComponent import com.zhangke.fread.common.publish.PublishPostManager import com.zhangke.fread.common.repo.LinkPreviewCardRepo import com.zhangke.fread.common.review.FreadReviewManager import com.zhangke.fread.common.startup.FeedsRepoModuleStartup import com.zhangke.fread.common.startup.FreadConfigModuleStartup import com.zhangke.fread.common.startup.StartupManager import com.zhangke.fread.common.status.StatusIdGenerator import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.common.status.adapter.ContentConfigAdapter import com.zhangke.fread.common.status.usecase.FormatStatusDisplayTimeUseCase import com.zhangke.fread.common.update.AppUpdateManager import org.koin.core.module.Module import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.bind import org.koin.dsl.module val commonModule = module { createPlatformModule() factoryOf(::CommonNavEntryProvider) bind NavEntryProvider::class factory { ApplicationScope } singleOf(::ActiveAccountsSynchronizer) singleOf(::BubbleManager) singleOf(::DayNightHelper) singleOf(::FreadConfigManager) singleOf(::FreadReviewManager) singleOf(::LocalConfigManager) singleOf(::OnboardingComponent) singleOf(::PublishPostManager) singleOf(::SelectedContentSwitcher) singleOf(::StartupManager) singleOf(::StatusUpdater) factoryOf(::LinkPreviewCardRepo) factoryOf(::AppUpdateManager) factoryOf(::BrowserLauncher) factoryOf(::ContentConfigAdapter) factoryOf(::FreadContentDbMigrateManager) factoryOf(::FreadContentRepo) factoryOf(::FormatStatusDisplayTimeUseCase) factoryOf(::MixedStatusRepo) factoryOf(::StatusIdGenerator) factoryOf(::StatusUiStateAdapter) factoryOf(::CommonStartup) bind ModuleStartup::class factoryOf(::FreadConfigModuleStartup) bind ModuleStartup::class factoryOf(::FeedsRepoModuleStartup) bind ModuleStartup::class viewModelOf(::SelectAccountForPublishViewModel) viewModel { params -> UrlRedirectViewModel( browserInterceptorSet = getAll(), browserLauncher = get(), statusProvider = get(), selectedContentSwitcher = get(), uri = params.get(), locator = params.getOrNull(), isFromExternal = params.get(), ) } } expect fun Module.createPlatformModule() ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/CommonNavEntryProvider.kt ================================================ package com.zhangke.fread.common import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.zhangke.framework.nav.dialogMetadata import com.zhangke.framework.nav.NavEntryProvider import com.zhangke.fread.common.browser.UrlRedirectScreen import com.zhangke.fread.common.browser.UrlRedirectScreenKey import com.zhangke.fread.common.deeplink.SelectAccountForPublishScreen import com.zhangke.fread.common.deeplink.SelectAccountForPublishScreenKey import kotlinx.serialization.modules.PolymorphicModuleBuilder import kotlinx.serialization.modules.subclass import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf class CommonNavEntryProvider : NavEntryProvider { override fun EntryProviderScope.build() { entry( metadata = dialogMetadata(), ) { SelectAccountForPublishScreen(koinViewModel { parametersOf(it.text) }) } entry( metadata = dialogMetadata(), ) { UrlRedirectScreen( uri = it.uri, viewModel = koinViewModel { parametersOf(it.uri, it.locator, it.isFromExternal) }, ) } } override fun PolymorphicModuleBuilder.polymorph() { subclass(SelectAccountForPublishScreenKey::class) subclass(UrlRedirectScreenKey::class) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/CommonStartup.kt ================================================ package com.zhangke.fread.common import com.zhangke.framework.module.ModuleStartup import com.zhangke.fread.common.account.ActiveAccountsSynchronizer import com.zhangke.fread.common.content.FreadContentDbMigrateManager import com.zhangke.fread.common.language.ActivityLanguageHelper class CommonStartup ( private val freadContentDbMigrateManager: FreadContentDbMigrateManager, private val activeAccountsSynchronizer: ActiveAccountsSynchronizer, private val activityLanguageHelper: ActivityLanguageHelper, ) : ModuleStartup { override fun onAppCreate() { freadContentDbMigrateManager.migrateOldDb() activeAccountsSynchronizer.initialize() activityLanguageHelper.initialize() } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/MixedContentJsonBuilder.kt ================================================ package com.zhangke.fread.common import com.zhangke.framework.architect.json.JsonModuleBuilder import com.zhangke.fread.status.content.MixedContent import com.zhangke.fread.status.model.FreadContent import com.zhangke.krouter.annotation.Service import kotlinx.serialization.modules.SerializersModuleBuilder import kotlinx.serialization.serializer @Service class MixedContentJsonBuilder : JsonModuleBuilder { override fun SerializersModuleBuilder.buildSerializersModule() { polymorphic( baseClass = FreadContent::class, actualClass = MixedContent::class, actualSerializer = serializer(), ) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/account/ActiveAccountsSynchronizer.kt ================================================ package com.zhangke.fread.common.account import com.zhangke.framework.architect.coroutines.ApplicationScope import com.zhangke.fread.common.config.FreadConfigManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class ActiveAccountsSynchronizer ( private val freadConfigManager: FreadConfigManager, ) { private val _activeAccountUriFlow = MutableStateFlow(null) val activeAccountUriFlow: StateFlow = _activeAccountUriFlow fun initialize() { ApplicationScope.launch { freadConfigManager.getLastSelectedAccount()?.let { _activeAccountUriFlow.value = it } } } suspend fun onAccountSelected(accountUri: String) { _activeAccountUriFlow.value = accountUri freadConfigManager.updateLastSelectedAccount(accountUri) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/action/ComposableActions.kt ================================================ package com.zhangke.fread.common.action import androidx.compose.runtime.staticCompositionLocalOf import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow val LocalComposableActions = staticCompositionLocalOf { ComposableActions } object ComposableActions { private val _actionFlow = MutableSharedFlow(replay = 1) val actionFlow: SharedFlow get() = _actionFlow suspend fun post(uri: String) { _actionFlow.emit(uri) } fun resetReplayCache() { _actionFlow.resetReplayCache() } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/action/RouteAction.kt ================================================ package com.zhangke.fread.common.action interface RouteAction { fun execute(): Boolean } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/action/RouteActions.kt ================================================ package com.zhangke.fread.common.action object RouteActions { const val ACTION_KEY = "route_action" const val BASE_URI = "fread://fread.xyz/action" } object OpenNotificationPageAction { const val URI = "${RouteActions.BASE_URI}/open_notification_page" fun buildOpenNotificationPageRoute(): String { return buildString { append(URI) } } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/adapter/StatusUiStateAdapter.kt ================================================ package com.zhangke.fread.common.adapter import com.zhangke.fread.status.model.BlogTranslationUiState import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.status.model.Status class StatusUiStateAdapter () { fun toStatusUiState( statusUiStatus: StatusUiState, status: Status, blogTranslationState: BlogTranslationUiState? = null, ): StatusUiState { return StatusUiState( status = status, locator = statusUiStatus.locator, logged = statusUiStatus.logged, isOwner = statusUiStatus.isOwner, blogTranslationState = blogTranslationState ?: BlogTranslationUiState( support = status.intrinsicBlog.supportTranslate, translating = false, showingTranslation = false, blogTranslation = null, ), ) } fun toStatusUiStateSnapshot( locator: PlatformLocator, status: Status, blogTranslationState: BlogTranslationUiState? = null, ): StatusUiState { return StatusUiState( status = status, locator = locator, logged = false, isOwner = false, blogTranslationState = blogTranslationState ?: BlogTranslationUiState( support = status.intrinsicBlog.supportTranslate, translating = false, showingTranslation = false, blogTranslation = null, ), ) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/browser/BrowserInterceptor.kt ================================================ package com.zhangke.fread.common.browser import androidx.navigation3.runtime.NavKey import com.zhangke.fread.status.model.FreadContent import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusProviderProtocol interface BrowserInterceptor { suspend fun intercept( locator: PlatformLocator?, url: String, isFromExternal: Boolean, ): InterceptorResult } sealed interface InterceptorResult { data object CanNotIntercept : InterceptorResult data class SuccessWithOpenNewScreen(val screen: NavKey) : InterceptorResult data class SwitchHomeContent(val content: FreadContent) : InterceptorResult data class RequireSelectAccount(val protocol: StatusProviderProtocol) : InterceptorResult } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/browser/BrowserLauncher.kt ================================================ package com.zhangke.fread.common.browser import androidx.compose.runtime.staticCompositionLocalOf import com.zhangke.framework.utils.PlatformUri import com.zhangke.framework.utils.toPlatformUri import com.zhangke.fread.common.config.AppCommonConfig import com.zhangke.fread.common.config.FreadConfigManager import com.zhangke.fread.common.utils.GlobalScreenNavigation import com.zhangke.fread.status.model.PlatformLocator import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch class BrowserLauncher( private val systemBrowserLauncher: SystemBrowserLauncher, private val freadConfigManager: FreadConfigManager, ) { fun launchBySystemBrowser(url: String) { launchBySystemBrowser(url.toPlatformUri()) } fun launchBySystemBrowser(uri: PlatformUri) { systemBrowserLauncher.launchBySystemBrowser(uri) } suspend fun launchWebTabInApp( url: String, locator: PlatformLocator? = null, checkAppSupportPage: Boolean = true, isFromExternal: Boolean = false, ) { launchWebTabInApp( uri = url.toPlatformUri(), locator = locator, checkAppSupportPage = checkAppSupportPage, isFromExternal = isFromExternal, ) } suspend fun launchWebTabInApp( uri: PlatformUri, locator: PlatformLocator? = null, checkAppSupportPage: Boolean = true, isFromExternal: Boolean = false, ) { if (checkAppSupportPage) { GlobalScreenNavigation.navigate( screen = UrlRedirectScreenKey( uri = uri.toString(), locator = locator, isFromExternal = isFromExternal, ), ) } else { if (freadConfigManager.openUrlInAppBrowser) { systemBrowserLauncher.launchWebTabInApp(uri) } else { systemBrowserLauncher.launchBySystemBrowser(uri) } } } suspend fun launchFreadLandingPage() { launchWebTabInApp(AppCommonConfig.WEBSITE) } suspend fun launchAuthorWebsite() { launchWebTabInApp(AppCommonConfig.AUTHOR_WEBSITE) } } val LocalActivityBrowserLauncher = staticCompositionLocalOf { error("No ActivityBrowserLauncher provided") } fun BrowserLauncher.launchWebTabInApp( scope: CoroutineScope, url: PlatformUri, locator: PlatformLocator? = null, checkAppSupportPage: Boolean = true, ) { scope.launch { this@launchWebTabInApp.launchWebTabInApp(url, locator, checkAppSupportPage) } } fun BrowserLauncher.launchWebTabInApp( scope: CoroutineScope, url: String, locator: PlatformLocator? = null, checkAppSupportPage: Boolean = true, ) { scope.launch { this@launchWebTabInApp.launchWebTabInApp(url, locator, checkAppSupportPage) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/browser/OAuthHandler.kt ================================================ package com.zhangke.fread.common.browser expect class OAuthHandler { suspend fun startOAuth(url: String): String } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/browser/SystemBrowserLauncher.kt ================================================ package com.zhangke.fread.common.browser import com.zhangke.framework.utils.PlatformUri interface SystemBrowserLauncher { fun launchBySystemBrowser(uri: PlatformUri) fun launchWebTabInApp(uri: PlatformUri) } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/browser/UrlRedirectScreen.kt ================================================ package com.zhangke.fread.common.browser import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.popIfNotRoot import com.zhangke.fread.common.composable.SelectableAccount import com.zhangke.fread.common.deeplink.SelectAccountForPublishScreenKey import com.zhangke.fread.common.deeplink.SelectedContentSwitcher import com.zhangke.fread.common.utils.GlobalScreenNavigation import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusProviderProtocol import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable data class UrlRedirectScreenKey( val uri: String, val locator: PlatformLocator? = null, val isFromExternal: Boolean = false, ) : NavKey @Composable fun UrlRedirectScreen(uri: String, viewModel: UrlRedirectViewModel) { val backStack = LocalNavBackStack.currentOrThrow val browserLauncher = LocalActivityBrowserLauncher.current ConsumeFlow(viewModel.finishPageFlow) { backStack.popIfNotRoot() } ConsumeFlow(viewModel.openNewPageFlow) { GlobalScreenNavigation.navigate(it) } ConsumeFlow(viewModel.finishAndOpenUrlTab) { browserLauncher.launchWebTabInApp( url = uri, locator = null, checkAppSupportPage = false, ) backStack.popIfNotRoot() } ConsumeFlow(viewModel.finishAndOpenPublishScreen) { backStack.popIfNotRoot() GlobalScreenNavigation.navigate(SelectAccountForPublishScreenKey(uri)) } val pageState by viewModel.pageState.collectAsState() Box( modifier = Modifier, contentAlignment = Alignment.Center, ) { when (pageState) { is UrlRedirectPageState.Loading -> { Surface( modifier = Modifier, shape = RoundedCornerShape(16.dp), ) { CircularProgressIndicator( modifier = Modifier .padding(vertical = 24.dp, horizontal = 64.dp) .size(80.dp) ) } } is UrlRedirectPageState.SelectAccount -> { val accountList = (pageState as UrlRedirectPageState.SelectAccount).accounts Surface( modifier = Modifier.padding(horizontal = 16.dp), shape = RoundedCornerShape(16.dp), shadowElevation = 2.dp, ) { Column( modifier = Modifier.fillMaxWidth() .padding(16.dp) .verticalScroll(rememberScrollState()), ) { Text( modifier = Modifier, text = stringResource(LocalizedString.statusUiSwitchAccountDialogTitle), style = MaterialTheme.typography.titleMedium .copy(fontWeight = FontWeight.SemiBold), ) for (account in accountList) { Spacer(modifier = Modifier.height(16.dp)) SelectableAccount( account = account, onClick = { viewModel.onAccountSelected(account) }, ) } } } } } } LaunchedEffect(Unit) { viewModel.onPageResumed() } } sealed interface UrlRedirectPageState { data object Loading : UrlRedirectPageState data class SelectAccount(val accounts: List) : UrlRedirectPageState } class UrlRedirectViewModel( private val browserInterceptorSet: List, val browserLauncher: BrowserLauncher, private val statusProvider: StatusProvider, private val selectedContentSwitcher: SelectedContentSwitcher, private val uri: String, private val locator: PlatformLocator?, private val isFromExternal: Boolean, ) : ViewModel() { private val _openNewPageFlow = MutableSharedFlow() val openNewPageFlow = _openNewPageFlow.asSharedFlow() private val _finishPageFlow = MutableSharedFlow() val finishPageFlow = _finishPageFlow.asSharedFlow() private val _finishAndOpenUrlTab = MutableSharedFlow() val finishAndOpenUrlTab = _finishAndOpenUrlTab.asSharedFlow() private val _finishAndOpenPublishScreen = MutableSharedFlow() val finishAndOpenPublishScreen = _finishAndOpenPublishScreen.asSharedFlow() private val _pageState = MutableStateFlow(UrlRedirectPageState.Loading) val pageState = _pageState.asStateFlow() private var accountSelectCount = 0 fun onPageResumed() { parseUrl(locator) } fun onAccountSelected(account: LoggedAccount) { parseUrl(account.locator) } private fun parseUrl(locator: PlatformLocator?) { launchInViewModel { _pageState.emit(UrlRedirectPageState.Loading) val result = browserInterceptorSet.firstNotNullOfOrNull { interceptor -> interceptor.intercept( locator = locator, url = uri, isFromExternal = isFromExternal, ).takeIf { it !is InterceptorResult.CanNotIntercept } } if (result == null) { if (isFromExternal) { _finishAndOpenPublishScreen.emit(uri) } else { _finishAndOpenUrlTab.emit(uri) } } else { when (result) { is InterceptorResult.SuccessWithOpenNewScreen -> { _finishPageFlow.emit(Unit) _openNewPageFlow.emit(result.screen) } is InterceptorResult.SwitchHomeContent -> { selectedContentSwitcher.switchToContent(result.content) _finishPageFlow.emit(Unit) } is InterceptorResult.RequireSelectAccount -> { if (accountSelectCount > 1) { _finishPageFlow.emit(Unit) } else { accountSelectCount++ val accounts = loadLoggedAccounts(result.protocol) if (accounts.isEmpty()) { _finishPageFlow.emit(Unit) } else { _pageState.emit(UrlRedirectPageState.SelectAccount(accounts)) } } } else -> { _finishPageFlow.emit(Unit) } } } } } private suspend fun loadLoggedAccounts(protocol: StatusProviderProtocol): List { return statusProvider.accountManager .getAllLoggedAccount() .filter { it.platform.protocol == protocol } } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/bubble/Bubble.kt ================================================ package com.zhangke.fread.common.bubble import androidx.compose.foundation.layout.ColumnScope import androidx.compose.runtime.Composable interface Bubble { @Composable fun ColumnScope.Content() } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/bubble/BubbleManager.kt ================================================ package com.zhangke.fread.common.bubble import androidx.compose.runtime.staticCompositionLocalOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow class BubbleManager () { private val _bubbleListFlow = MutableStateFlow>(emptyList()) val bubbleListFlow get() = _bubbleListFlow.asStateFlow() suspend fun addBubble(bubble: Bubble) { _bubbleListFlow.emit(_bubbleListFlow.value + bubble) } } val LocalBubbleManager = staticCompositionLocalOf { error("No BubbleManager provided") } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/composable/EmptyContent.kt ================================================ package com.zhangke.fread.common.composable import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.zhangke.fread.commonbiz.Res import com.zhangke.fread.commonbiz.img_empty_account import com.zhangke.fread.commonbiz.img_empty_content import com.zhangke.fread.commonbiz.img_empty_message import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource enum class EmptyContentType { Content, Message, Account; fun getDrawableResource(): DrawableResource { return when (this) { Content -> Res.drawable.img_empty_content Message -> Res.drawable.img_empty_message Account -> Res.drawable.img_empty_account } } } @Composable fun EmptyContent( modifier: Modifier, type: EmptyContentType = EmptyContentType.Content, contentTitle: String = stringResource(LocalizedString.emptyContentHintTitle), subtitle: String? = stringResource(LocalizedString.emptyContentHintDesc), onClick: (() -> Unit)? = null, ) { Column( modifier = modifier.verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { Image( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), contentScale = ContentScale.Inside, painter = painterResource(type.getDrawableResource()), contentDescription = null, ) Text( modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp), text = contentTitle, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, ) if (!subtitle.isNullOrEmpty()) { Text( modifier = Modifier.padding(start = 32.dp, top = 8.dp, end = 32.dp), text = subtitle, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, textAlign = TextAlign.Center, ) } } if (onClick != null) { Button( modifier = Modifier.padding(top = 32.dp), onClick = onClick, ) { Text(text = stringResource(LocalizedString.feedsAddContent)) } } } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/composable/ErrorContent.kt ================================================ package com.zhangke.fread.common.composable import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.zhangke.fread.commonbiz.Res import com.zhangke.fread.commonbiz.img_error_state import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource enum class ErrorType { Unknown, Network, NotFound, } @Composable fun ErrorContent( modifier: Modifier, errorMessage: String?, onRetryClick: () -> Unit, type: ErrorType = ErrorType.Unknown, ) { Column( modifier = modifier.verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Image( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), contentScale = ContentScale.Inside, painter = painterResource(Res.drawable.img_error_state), contentDescription = null, ) Text( modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp), text = stringResource( when (type) { ErrorType.Unknown -> LocalizedString.unknownError ErrorType.Network -> LocalizedString.networkError ErrorType.NotFound -> LocalizedString.resourceNotFound } ), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, ) if (!errorMessage.isNullOrEmpty()) { Text( modifier = Modifier.padding(start = 32.dp, top = 8.dp, end = 32.dp), text = errorMessage, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, maxLines = 2, textAlign = TextAlign.Center, ) } Spacer(modifier = Modifier.height(16.dp)) Button(onClick = onRetryClick) { Text(text = stringResource(LocalizedString.retry)) } } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/composable/SelectableAccount.kt ================================================ package com.zhangke.fread.common.composable import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.seiko.imageloader.model.ImageAction import com.seiko.imageloader.model.ImageRequest import com.seiko.imageloader.rememberImageActionPainter import com.seiko.imageloader.ui.AutoSizeBox import com.zhangke.framework.composable.freadPlaceholder import com.zhangke.framework.composable.noRippleClick import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.richtext.RichText @Composable internal fun SelectableAccount( account: LoggedAccount, onClick: (LoggedAccount) -> Unit, ) { Row( modifier = Modifier.fillMaxWidth().noRippleClick { onClick(account) }, verticalAlignment = Alignment.CenterVertically, ) { AutoSizeBox( request = remember(account.avatar) { ImageRequest(account.avatar.orEmpty()) }, ) { action -> Image( painter = rememberImageActionPainter(action), contentDescription = "Avatar", modifier = Modifier.size(42.dp) .clip(CircleShape) .freadPlaceholder(action !is ImageAction.Success) .clickable { onClick(account) }, ) } Column(modifier = Modifier.padding(start = 16.dp).weight(1F)) { Text( text = account.humanizedName.document, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = account.prettyHandle, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/config/AppCommonConfig.kt ================================================ package com.zhangke.fread.common.config object AppCommonConfig { const val APP_NAME = "Fread" const val WEBSITE = "https://fread.xyz" const val AUTHOR = "Zhang Ke" const val AUTHOR_WEBSITE = "https://zhangke.space" const val AUTHOR_EMAIL = "kezhang404@gmail.com" const val PRIVACY_POLICY = "https://fread.xyz/appprivacy.html" const val FEEDBACK_URL = "https://github.com/0xZhangKe/Fread/issues/new" const val TELEGRAM_GROUP = "https://t.me/+-SlbKcNbJSphNWI1" const val DONATE_KO_FI_LINK = "https://ko-fi.com/zhangke" const val DONATE_AF_DIAN_LINK = "https://afdian.com/a/_0cdc1" const val F_DROID_URI = "https://f-droid.org/zh_Hans/packages/com.zhangke.fread" } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/config/FreadConfigManager.kt ================================================ package com.zhangke.fread.common.config import androidx.compose.runtime.staticCompositionLocalOf import com.zhangke.fread.common.theme.ThemeType import com.zhangke.fread.common.utils.RandomIdGenerator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.withContext class FreadConfigManager( private val localConfigManager: LocalConfigManager, ) { companion object { private const val LOCAL_KEY_AUTO_PLAY_INLINE_VIDEO = "auto_play_inline_video" private const val LOCAL_KEY_STATUS_CONTENT_SIZE = "fread_status_content_size" private const val LOCAL_KEY_STATUS_ALWAYS_SHOW_SENSITIVE = "fread_status_always_show_sensitive" private const val LOCAL_KEY_IMMERSIVE_NAV_BAR = "immersiveNavBar" private const val LOCAL_KEY_HOME_TAB_NEXT_BUTTON_VISIBLE = "home_tab_next_button_visible" private const val LOCAL_KEY_HOME_TAB_REFRESH_BUTTON_VISIBLE = "home_tab_refresh_button_visible" private const val LOCAL_KEY_DEVICE_ID = "device_id" private const val LOCAL_KEY_IGNORE_UPDATE_VERSION = "ignore_update_version" private const val LOCAL_KEY_BSKY_PUBLISH_LAN = "bsky_publish_lan" private const val LOCAL_KEY_LAST_SELECTED_ACCOUNT = "last_selected_account" private const val LOCAL_KEY_TIMELINE_DEFAULT_POSITION = "timeline_default_position" private const val LOCAL_KEY_THEME_TYPE = "theme_type" private const val LOCAL_KEY_OPEN_URL_IN_APP_BROWSER = "open_url_in_app_browser" private const val LOCAL_KEY_ENABLE_BLUR_APP_BAR_STYLE = "enable_blur_app_bar_style" } private val _statusConfigFlow = MutableStateFlow(StatusConfig.default()) val statusConfigFlow get(): StateFlow = _statusConfigFlow private val _themeTypeeFlow = MutableStateFlow(ThemeType.DEFAULT) val themeTypeFlow get() = _themeTypeeFlow private val _homeTabNextButtonVisibleFlow = MutableStateFlow(false) val homeTabNextButtonVisibleFlow: StateFlow get() = _homeTabNextButtonVisibleFlow private val _homeTabRefreshButtonVisibleFlow = MutableStateFlow(false) val homeTabRefreshButtonVisibleFlow: StateFlow get() = _homeTabRefreshButtonVisibleFlow private val _enableBlurAppBarStyleFlow = MutableStateFlow(true) val enableBlurAppBarStyleFlow: StateFlow get() = _enableBlurAppBarStyleFlow var autoPlayInlineVideo: Boolean = false private set var openUrlInAppBrowser: Boolean = true private set suspend fun initConfig() { _statusConfigFlow.value = readLocalStatusConfig() _themeTypeeFlow.value = getThemeType() autoPlayInlineVideo = localConfigManager.getBoolean(LOCAL_KEY_AUTO_PLAY_INLINE_VIDEO) ?: false openUrlInAppBrowser = localConfigManager.getBoolean(LOCAL_KEY_OPEN_URL_IN_APP_BROWSER) != false _enableBlurAppBarStyleFlow.value = localConfigManager.getBoolean(LOCAL_KEY_ENABLE_BLUR_APP_BAR_STYLE) != false _homeTabNextButtonVisibleFlow.value = localConfigManager.getBoolean(LOCAL_KEY_HOME_TAB_NEXT_BUTTON_VISIBLE) ?: false _homeTabRefreshButtonVisibleFlow.value = localConfigManager.getBoolean(LOCAL_KEY_HOME_TAB_REFRESH_BUTTON_VISIBLE) ?: false } private suspend fun readLocalStatusConfig(): StatusConfig { val alwaysShowSensitiveContent = localConfigManager.getBoolean(LOCAL_KEY_STATUS_ALWAYS_SHOW_SENSITIVE) == true val contentSize = localConfigManager.getString(LOCAL_KEY_STATUS_CONTENT_SIZE) ?.toContentSize() ?: StatusContentSize.default() val immersiveNavBar = localConfigManager.getBoolean(LOCAL_KEY_IMMERSIVE_NAV_BAR) != false return StatusConfig( alwaysShowSensitiveContent = alwaysShowSensitiveContent, contentSize = contentSize, immersiveNavBar = immersiveNavBar, ) } private fun String.toContentSize(): StatusContentSize? { return runCatching { StatusContentSize.valueOf(this) }.getOrNull() } suspend fun updateAutoPlayInlineVideo(value: Boolean) { autoPlayInlineVideo = value withContext(Dispatchers.IO) { localConfigManager.putBoolean(LOCAL_KEY_AUTO_PLAY_INLINE_VIDEO, value) } } suspend fun updateOpenUrlInAppBrowser(value: Boolean) { openUrlInAppBrowser = value withContext(Dispatchers.IO) { localConfigManager.putBoolean(LOCAL_KEY_OPEN_URL_IN_APP_BROWSER, value) } } suspend fun updateEnableBlurAppBarStyle(value: Boolean) { _enableBlurAppBarStyleFlow.value = value withContext(Dispatchers.IO) { localConfigManager.putBoolean(LOCAL_KEY_ENABLE_BLUR_APP_BAR_STYLE, value) } } suspend fun updateStatusContentSize(contentSize: StatusContentSize) { _statusConfigFlow.value = _statusConfigFlow.value.copy(contentSize = contentSize) withContext(Dispatchers.IO) { localConfigManager.putString( LOCAL_KEY_STATUS_CONTENT_SIZE, contentSize.name, ) } } suspend fun updateAlwaysShowSensitiveContent(always: Boolean) { _statusConfigFlow.value = _statusConfigFlow.value.copy(alwaysShowSensitiveContent = always) withContext(Dispatchers.IO) { localConfigManager.putBoolean(LOCAL_KEY_STATUS_ALWAYS_SHOW_SENSITIVE, always) } } suspend fun updateImmersiveNavBar(immersive: Boolean) { _statusConfigFlow.value = _statusConfigFlow.value.copy(immersiveNavBar = immersive) withContext(Dispatchers.IO) { localConfigManager.putBoolean(LOCAL_KEY_IMMERSIVE_NAV_BAR, immersive) } } suspend fun updateHomeTabNextButtonVisible(visible: Boolean) { _homeTabNextButtonVisibleFlow.value = visible withContext(Dispatchers.IO) { localConfigManager.putBoolean(LOCAL_KEY_HOME_TAB_NEXT_BUTTON_VISIBLE, visible) } } suspend fun updateHomeTabRefreshButtonVisible(visible: Boolean) { _homeTabRefreshButtonVisibleFlow.value = visible withContext(Dispatchers.IO) { localConfigManager.putBoolean(LOCAL_KEY_HOME_TAB_REFRESH_BUTTON_VISIBLE, visible) } } suspend fun getDeviceId(): String { return localConfigManager.getStringOrPut(LOCAL_KEY_DEVICE_ID) { RandomIdGenerator().generateId() } } suspend fun getIgnoreUpdateVersion(): Long? { return localConfigManager.getLong(LOCAL_KEY_IGNORE_UPDATE_VERSION) } suspend fun updateIgnoreUpdateVersion(version: Long) { withContext(Dispatchers.IO) { localConfigManager.putLong(LOCAL_KEY_IGNORE_UPDATE_VERSION, version) } } suspend fun getBskyPublishLanguage(): List { return localConfigManager.getString(LOCAL_KEY_BSKY_PUBLISH_LAN)?.split(",") ?: emptyList() } suspend fun updateBskyPublishLanguage(languages: List) { withContext(Dispatchers.IO) { localConfigManager.putString(LOCAL_KEY_BSKY_PUBLISH_LAN, languages.joinToString(",")) } } suspend fun getLastSelectedAccount(): String? { return localConfigManager.getString(LOCAL_KEY_LAST_SELECTED_ACCOUNT) } suspend fun updateLastSelectedAccount(accountUri: String) { localConfigManager.putString(LOCAL_KEY_LAST_SELECTED_ACCOUNT, accountUri) } suspend fun getTimelineDefaultPosition(): TimelineDefaultPosition { return localConfigManager.getString(LOCAL_KEY_TIMELINE_DEFAULT_POSITION) ?.let { TimelineDefaultPosition.valueOf(it) } ?: TimelineDefaultPosition.NEWEST } suspend fun updateTimelineDefaultPosition(position: TimelineDefaultPosition) { localConfigManager.putString(LOCAL_KEY_TIMELINE_DEFAULT_POSITION, position.name) } suspend fun getThemeType(): ThemeType { return localConfigManager.getString(LOCAL_KEY_THEME_TYPE) ?.let { ThemeType.valueOf(it) } ?: ThemeType.DEFAULT } suspend fun updateThemeType(type: ThemeType) { _themeTypeeFlow.value = type localConfigManager.putString(LOCAL_KEY_THEME_TYPE, type.name) } } val LocalFreadConfigManager = staticCompositionLocalOf { error("No FreadConfigManager provided") } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/config/LocalConfigManager.kt ================================================ package com.zhangke.fread.common.config import androidx.compose.runtime.staticCompositionLocalOf import com.russhwolf.settings.coroutines.FlowSettings class LocalConfigManager(private val configSettings: FlowSettings) { suspend fun getString(key: String): String? { return configSettings.getStringOrNull(key) } suspend fun getStringOrPut(key: String, block: () -> String): String { val value = configSettings.getStringOrNull(key) return value ?: block().also { configSettings.putString(key, it) } } suspend fun putString(key: String, value: String) { configSettings.putString(key, value) } suspend fun getInt(key: String): Int? { return configSettings.getIntOrNull(key) } suspend fun putInt(key: String, value: Int) { configSettings.putInt(key, value) } suspend fun getLong(key: String): Long? { return configSettings.getLongOrNull(key) } suspend fun putLong(key: String, value: Long) { configSettings.putLong(key, value) } suspend fun getBoolean(key: String): Boolean? { return configSettings.getBooleanOrNull(key) } suspend fun putBoolean(key: String, value: Boolean) { configSettings.putBoolean(key, value) } suspend fun removeKey(key: String) { configSettings.remove(key) } } val LocalLocalConfigManager = staticCompositionLocalOf { error("No LocalConfigManager provided") } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/config/StatusConfig.kt ================================================ package com.zhangke.fread.common.config data class StatusConfig( val alwaysShowSensitiveContent: Boolean, val contentSize: StatusContentSize, val immersiveNavBar: Boolean, ) { companion object { fun default( alwaysShowSensitiveContent: Boolean = false, contentSize: StatusContentSize = StatusContentSize.default(), immersiveNavBar: Boolean = true, ) = StatusConfig( alwaysShowSensitiveContent = alwaysShowSensitiveContent, contentSize = contentSize, immersiveNavBar = immersiveNavBar, ) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/config/StatusContentSize.kt ================================================ package com.zhangke.fread.common.config enum class StatusContentSize { SMALL, MEDIUM, LARGE; companion object { fun default(): StatusContentSize = MEDIUM } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/config/TimelineDefaultPosition.kt ================================================ package com.zhangke.fread.common.config enum class TimelineDefaultPosition { NEWEST, LAST_READ, } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/content/FreadContentDbMigrateManager.kt ================================================ package com.zhangke.fread.common.content import com.zhangke.framework.architect.coroutines.ApplicationScope import com.zhangke.fread.common.db.ContentConfigDatabases import com.zhangke.fread.common.status.adapter.ContentConfigAdapter import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.content.MixedContent import com.zhangke.fread.status.model.ContentConfig import com.zhangke.fread.status.model.FreadContent import kotlinx.coroutines.launch class FreadContentDbMigrateManager ( private val contentRepo: FreadContentRepo, private val statusProvider: StatusProvider, private val contentConfigDatabases: ContentConfigDatabases, private val contentConfigAdapter: ContentConfigAdapter, ) { internal fun migrateOldDb() { ApplicationScope.launch { migrateContentConfig() migrateOldMixedContent() } } private suspend fun migrateContentConfig() { val contentConfigList = contentConfigDatabases.getContentConfigDao() .queryAllContentConfig() if (contentConfigList.isEmpty()) return contentConfigList .map(contentConfigAdapter::toContentConfig) .mapNotNull { it.toContent() } .let { contentRepo.insertAll(it) } contentConfigDatabases.getContentConfigDao().deleteTable() } private fun ContentConfig.toContent(): FreadContent? { if (this is ContentConfig.MixedContent) { return MixedContent( id = this.id.toString(), order = this.order, name = this.name, sourceUriList = this.sourceUriList, ) } return statusProvider.contentManager.restoreContent(this) } private suspend fun migrateOldMixedContent() { contentRepo.getAllOldContents() .mapNotNull { it.second as? MixedContent } .forEach { content -> contentRepo.insertContent(content) contentRepo.deleteOldContents(content.id) } } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/content/FreadContentRepo.kt ================================================ package com.zhangke.fread.common.content import com.zhangke.fread.common.db.FreadContentDatabase import com.zhangke.fread.common.db.FreadContentEntity import com.zhangke.fread.common.db.old.OldFreadContentDatabase import com.zhangke.fread.status.model.FreadContent import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull class FreadContentRepo ( database: FreadContentDatabase, private val oldContentDatabase: OldFreadContentDatabase, ) { private val dao = database.contentDao() suspend fun getAllOldContents(): List> { return oldContentDatabase.contentDao().queryAll().map { it.id to it.content } } suspend fun deleteOldContents(id: String) { oldContentDatabase.contentDao().delete(id) } fun getAllContentFlow() = dao.queryAllFlow().map { list -> list.map { it.content }.sortedBy { it.order } } fun getContentFlow(id: String) = dao.queryFlow(id).mapNotNull { it?.content } suspend fun getAllContent(): List { return dao.queryAll().map { it.content }.sortedBy { it.order } } suspend fun getContent(id: String): FreadContent? { return dao.query(id)?.content } suspend fun insertContent(content: FreadContent) { dao.insert(content.toEntity()) } suspend fun insertAll(content: List) { dao.insertAll(content.map { it.toEntity() }) } suspend fun delete(id: String) { dao.delete(id) } suspend fun getMaxOrder(): Int { return getAllContent().maxOfOrNull { it.order } ?: 0 } suspend fun checkNameExist(name: String): Boolean { return getAllContent().any { it.name == name } } suspend fun reorderConfig(from: FreadContent, to: FreadContent) { if (from == to) return val pendingInsertList = mutableListOf() pendingInsertList += from.newOrder(to.order) val allConfig = getAllContent() if (from.order > to.order) { // move up allConfig.filter { it.order in to.order until from.order } .map { it.newOrder(it.order + 1) } .let { pendingInsertList += it } } else { // move down allConfig.filter { it.order > from.order && it.order <= to.order } .map { it.newOrder(it.order - 1) } .let { pendingInsertList += it } } insertAll(pendingInsertList) } private fun FreadContent.toEntity(): FreadContentEntity { return FreadContentEntity( id = this.id, content = this, ) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/daynight/DayNightHelper.kt ================================================ package com.zhangke.fread.common.daynight import androidx.compose.runtime.staticCompositionLocalOf import com.zhangke.fread.common.config.LocalConfigManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.runBlocking class DayNightHelper ( private val localConfigManager: LocalConfigManager, ) { companion object { private const val DAY_NIGHT_SETTING = "day_night_setting" private const val AMOLED_MODE = "amoled_mode" } private val _dayNightModeFlow: MutableStateFlow val dayNightModeFlow: StateFlow private val _amoledModeFlow: MutableStateFlow val amoledModeFlow: StateFlow private val dayNightPlatformHelper = DayNightPlatformHelper() init { val (model, amoledEnabled) = runBlocking { getDayNightModeSetting() to getAmoledModeSetting() } dayNightPlatformHelper.setDefaultMode(model) _dayNightModeFlow = MutableStateFlow(model) dayNightModeFlow = _dayNightModeFlow.asStateFlow() _amoledModeFlow = MutableStateFlow(amoledEnabled) amoledModeFlow = _amoledModeFlow.asStateFlow() } fun setDefaultMode() { dayNightPlatformHelper.setDefaultMode(dayNightModeFlow.value) } // FIXME: use ActivityDayNightHelper.setMode before https://github.com/adrielcafe/voyager/issues/489 fix suspend fun setMode(mode: DayNightMode) { _dayNightModeFlow.value = mode localConfigManager.putInt(DAY_NIGHT_SETTING, mode.localKey) dayNightPlatformHelper.setMode(mode) } suspend fun setAmoledMode(enabled: Boolean) { _amoledModeFlow.value = enabled localConfigManager.putBoolean(AMOLED_MODE, enabled) dayNightPlatformHelper.setAmoledMode(enabled) } private suspend fun getDayNightModeSetting(): DayNightMode { return localConfigManager.getInt(DAY_NIGHT_SETTING) ?.let { DayNightMode.fromLocalKey(it) } ?: DayNightMode.FOLLOW_SYSTEM } private suspend fun getAmoledModeSetting(): Boolean { return localConfigManager.getBoolean(AMOLED_MODE) ?: false } } expect class DayNightPlatformHelper() { fun setDefaultMode(modeValue: DayNightMode) fun setMode(mode: DayNightMode) fun setAmoledMode(enabled: Boolean) } val LocalActivityDayNightHelper = staticCompositionLocalOf { error("No ActivityDayNightHelper provided") } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/daynight/DayNightMode.kt ================================================ package com.zhangke.fread.common.daynight import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable enum class DayNightMode(val localKey: Int) { DAY(1), NIGHT(2), FOLLOW_SYSTEM(-1), ; val isNight: Boolean @ReadOnlyComposable @Composable get() { return when (this) { DAY -> false NIGHT -> true FOLLOW_SYSTEM -> isSystemInDarkTheme() } } companion object { fun fromLocalKey(key: Int): DayNightMode { return when (key) { DAY.localKey -> DAY NIGHT.localKey -> NIGHT FOLLOW_SYSTEM.localKey -> FOLLOW_SYSTEM else -> FOLLOW_SYSTEM } } } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/ContentConfigDatabases.kt ================================================ package com.zhangke.fread.common.db import androidx.room.ConstructedBy import androidx.room.Dao import androidx.room.Database import androidx.room.Delete import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.RoomDatabase import androidx.room.RoomDatabaseConstructor import androidx.room.TypeConverters import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.common.db.converts.ContentTabConverter import com.zhangke.fread.common.db.converts.ContentTypeConverter import com.zhangke.fread.common.db.converts.FormalBaseUrlConverter import com.zhangke.fread.common.db.converts.FormalUriConverter import com.zhangke.fread.common.db.converts.StatusProviderUriListConverter import com.zhangke.fread.status.model.ContentConfig import com.zhangke.fread.status.model.ContentType import com.zhangke.fread.status.uri.FormalUri import kotlinx.coroutines.flow.Flow private const val DB_VERSION = 1 private const val TABLE_NAME = "content_configs" @TypeConverters( ContentTabConverter::class, ) @Entity(tableName = TABLE_NAME) data class ContentConfigEntity( @PrimaryKey(autoGenerate = true) val id: Long, val order: Int, val name: String, val type: ContentType, val sourceUriList: List?, val baseUrl: FormalBaseUrl?, val showingTabList: List, val hiddenTabList: List, ) @Dao interface ContentConfigDao { @Query("SELECT * FROM $TABLE_NAME ORDER BY `order` ASC") suspend fun queryAllContentConfig(): List @Query("SELECT * FROM $TABLE_NAME ORDER BY `order` ASC") fun queryAllContentConfigFlow(): Flow> @Query("SELECT * FROM $TABLE_NAME WHERE id=:id") fun getContentConfigFlow(id: Long): Flow @Query("SELECT * FROM $TABLE_NAME WHERE name=:name") suspend fun queryByName(name: String): ContentConfigEntity? @Query("UPDATE $TABLE_NAME SET sourceUriList=:sourceUriList WHERE id=:id") suspend fun updateSourceList(id: Long, sourceUriList: List) @Query("UPDATE $TABLE_NAME SET name=:name WHERE id=:id") suspend fun updateName(id: Long, name: String) @Query("SELECT * FROM $TABLE_NAME WHERE id=:id") suspend fun queryById(id: Long): ContentConfigEntity? @Query("SELECT * FROM $TABLE_NAME WHERE id=:id") fun queryFlowById(id: Long): Flow @Query("SELECT MAX(`order`) FROM $TABLE_NAME") suspend fun queryMaxOrder(): Int? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entity: ContentConfigEntity) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertList(entities: List) @Delete suspend fun delete(entity: ContentConfigEntity) @Query("DELETE FROM $TABLE_NAME") suspend fun deleteTable() @Query("DELETE FROM $TABLE_NAME WHERE id=:id") suspend fun deleteById(id: Long) } @TypeConverters( ContentTabConverter::class, ContentTypeConverter::class, FormalUriConverter::class, StatusProviderUriListConverter::class, FormalBaseUrlConverter::class, ) @Database( entities = [ContentConfigEntity::class], version = DB_VERSION, exportSchema = false, ) @ConstructedBy(ContentConfigDatabasesConstructor::class) abstract class ContentConfigDatabases : RoomDatabase() { abstract fun getContentConfigDao(): ContentConfigDao companion object { const val DB_NAME = "ContentConfig.db" } } // The Room compiler generates the `actual` implementations. @Suppress("NO_ACTUAL_FOR_EXPECT") expect object ContentConfigDatabasesConstructor : RoomDatabaseConstructor { override fun initialize(): ContentConfigDatabases } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/FreadContentDatabase.kt ================================================ package com.zhangke.fread.common.db import androidx.room.ConstructedBy import androidx.room.Dao import androidx.room.Database import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.RoomDatabase import androidx.room.RoomDatabaseConstructor import androidx.room.TypeConverters import com.zhangke.fread.common.db.converts.FreadContentConverter import com.zhangke.fread.status.model.FreadContent import kotlinx.coroutines.flow.Flow private const val DB_VERSION = 1 private const val TABLE_NAME = "fread_content" @Entity(tableName = TABLE_NAME) data class FreadContentEntity( @PrimaryKey val id: String, val content: FreadContent, ) @Dao interface FreadContentDao { @Query("SELECT * FROM $TABLE_NAME") fun queryAllFlow(): Flow> @Query("SELECT * FROM $TABLE_NAME WHERE id = :id") fun queryFlow(id: String): Flow @Query("SELECT * FROM $TABLE_NAME") suspend fun queryAll(): List @Query("SELECT * FROM $TABLE_NAME WHERE id = :id") suspend fun query(id: String): FreadContentEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(content: FreadContentEntity) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(list: List) @Query("DELETE FROM $TABLE_NAME WHERE id = :id") suspend fun delete(id: String) } @TypeConverters( FreadContentConverter::class ) @Database( entities = [FreadContentEntity::class], version = DB_VERSION, exportSchema = false, ) @ConstructedBy(FreadContentDatabaseConstructor::class) abstract class FreadContentDatabase : RoomDatabase() { abstract fun contentDao(): FreadContentDao companion object { const val DB_NAME = "fread_content_1.db" } } // The Room compiler generates the `actual` implementations. @Suppress("NO_ACTUAL_FOR_EXPECT") expect object FreadContentDatabaseConstructor : RoomDatabaseConstructor { override fun initialize(): FreadContentDatabase } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/MixedStatusDatabases.kt ================================================ package com.zhangke.fread.common.db import androidx.room.ConstructedBy import androidx.room.Dao import androidx.room.Database import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.RoomDatabase import androidx.room.RoomDatabaseConstructor import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.SQLiteConnection import androidx.sqlite.execSQL import com.zhangke.fread.common.db.converts.FormalUriConverter import com.zhangke.fread.common.db.converts.StatusConverter import com.zhangke.fread.common.db.converts.StatusUiStateConverter import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.uri.FormalUri import kotlinx.coroutines.flow.Flow private const val DB_VERSION = 2 private const val TABLE_NAME = "mixed_status" @Entity(tableName = TABLE_NAME, primaryKeys = ["sourceUri", "statusId"]) data class MixedStatusEntity( val statusId: String, val sourceUri: FormalUri, val status: StatusUiState, val createAt: Long, val cursor: String?, ) @Dao interface MixedStatusDao { @Query("SELECT * FROM $TABLE_NAME WHERE sourceUri IN (:sourceUris) ORDER BY createAt DESC") fun queryFlow(sourceUris: List): Flow> @Query("SELECT * FROM $TABLE_NAME WHERE statusId = :statusId") suspend fun queryByStatusId(statusId: String): List @Query("SELECT * FROM $TABLE_NAME WHERE sourceUri IN (:sourceUris) ORDER BY createAt DESC") suspend fun queryAll(sourceUris: List): List @Query("SELECT * FROM $TABLE_NAME WHERE sourceUri = :sourceUri ORDER BY createAt DESC") suspend fun query(sourceUri: FormalUri): List @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertStatus(status: List) @Query("DELETE FROM $TABLE_NAME WHERE sourceUri IN (:sourceUris)") suspend fun deleteStatusOfSources(sourceUris: List) @Query("DELETE FROM $TABLE_NAME WHERE sourceUri = :sourceUri") suspend fun deleteStatusOfSource(sourceUri: FormalUri) } @TypeConverters( StatusConverter::class, StatusUiStateConverter::class, FormalUriConverter::class, ) @Database( entities = [MixedStatusEntity::class], version = DB_VERSION, exportSchema = false, ) @ConstructedBy(MixedStatusDatabasesConstructor::class) abstract class MixedStatusDatabases : RoomDatabase() { abstract fun mixedStatusDao(): MixedStatusDao companion object { const val DB_NAME = "mixed_status_1.db" val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(connection: SQLiteConnection) { connection.execSQL("DELETE FROM $TABLE_NAME") } } } } // The Room compiler generates the `actual` implementations. @Suppress("NO_ACTUAL_FOR_EXPECT") expect object MixedStatusDatabasesConstructor : RoomDatabaseConstructor { override fun initialize(): MixedStatusDatabases } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/converts/BlogMediaConverterHelper.kt ================================================ package com.zhangke.fread.common.db.converts import androidx.room.TypeConverter import com.zhangke.framework.architect.json.globalJson import com.zhangke.fread.status.blog.BlogMedia import com.zhangke.fread.status.blog.BlogMediaMeta import com.zhangke.fread.status.blog.BlogMediaType import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive class BlogMediaConverterHelper { fun fromJsonObject(jsonObject: JsonObject?): BlogMedia? { if (jsonObject == null) return null return BlogMedia( id = jsonObject.getString("id"), url = jsonObject.getString("url"), type = jsonObject.getString("type").let(BlogMediaType::valueOf), previewUrl = jsonObject.getStringOrNull("previewUrl"), remoteUrl = jsonObject.getStringOrNull("remoteUrl"), description = jsonObject.getStringOrNull("description"), blurhash = jsonObject.getStringOrNull("blurhash"), meta = jsonObject.get("meta")?.jsonObject?.let(::convertJsonObjectToMeta), ) } fun toJsonObject(media: BlogMedia?): JsonObject? { if (media == null) return null val map = buildMap { put("id", JsonPrimitive(media.id)) put("url", JsonPrimitive(media.url)) put("type", JsonPrimitive(media.type.name)) if (media.previewUrl != null) put("previewUrl", JsonPrimitive(media.previewUrl)) if (media.remoteUrl != null) put("remoteUrl", JsonPrimitive(media.remoteUrl)) if (media.description != null) put("description", JsonPrimitive(media.description)) if (media.blurhash != null) put("blurhash", JsonPrimitive(media.blurhash)) media.meta ?.let { convertMetaToJsonObject(it) } ?.let { put("meta", it) } } return JsonObject(map) } private fun convertJsonObjectToMeta(jsonObject: JsonObject): BlogMediaMeta { return when (jsonObject.getStringOrNull("type")?.let(::toType)) { BlogMediaType.IMAGE -> { globalJson.decodeFromString( jsonObject.getStringOrNull("data").orEmpty() ) } BlogMediaType.GIFV -> { globalJson.decodeFromString( jsonObject.getStringOrNull("data").orEmpty() ) } BlogMediaType.VIDEO -> { globalJson.decodeFromString( jsonObject.getStringOrNull("data").orEmpty() ) } BlogMediaType.AUDIO -> { globalJson.decodeFromString( jsonObject.getStringOrNull("data").orEmpty() ) } else -> throw IllegalArgumentException("Unknown type!") } } @TypeConverter private fun convertMetaToJsonObject(meta: BlogMediaMeta): JsonObject { val type = when (meta) { is BlogMediaMeta.ImageMeta -> BlogMediaType.IMAGE is BlogMediaMeta.GifvMeta -> BlogMediaType.GIFV is BlogMediaMeta.VideoMeta -> BlogMediaType.VIDEO is BlogMediaMeta.AudioMeta -> BlogMediaType.AUDIO } val map = buildMap { put("type", JsonPrimitive(type.name)) put("data", JsonPrimitive(globalJson.encodeToString(meta))) } return JsonObject(map) } private fun toType(typeName: String): BlogMediaType { return BlogMediaType.valueOf(typeName) } private fun JsonObject.getString(key: String): String { return get(key)?.jsonPrimitive?.contentOrNull.orEmpty() } private fun JsonObject.getStringOrNull(key: String): String? { return get(key)?.jsonPrimitive?.contentOrNull } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/converts/BlogMediaListConverter.kt ================================================ package com.zhangke.fread.common.db.converts import androidx.room.TypeConverter import com.zhangke.framework.architect.json.globalJson import com.zhangke.fread.status.blog.BlogMedia import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.jsonObject class BlogMediaListConverter { private val mediaConvertHelper = BlogMediaConverterHelper() @TypeConverter fun fromStringList(text: String): List { val jsonArray: JsonArray = globalJson.decodeFromString(text) val list = mutableListOf() jsonArray.forEach { element -> mediaConvertHelper.fromJsonObject(element.jsonObject)?.let { list += it } } return list } @TypeConverter fun toStringList(list: List): String { return JsonArray( buildList { list.forEach { media -> mediaConvertHelper.toJsonObject(media)?.let { add(it) } } }, ).toString() } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/converts/BlogPollConverter.kt ================================================ package com.zhangke.fread.common.db.converts import androidx.room.TypeConverter import com.zhangke.framework.architect.json.globalJson import com.zhangke.fread.status.blog.BlogPoll import kotlinx.serialization.encodeToString class BlogPollConverter { @TypeConverter fun fromStringList(text: String?): BlogPoll? { if (text.isNullOrEmpty()) return null return globalJson.decodeFromString(text) } @TypeConverter fun toStringList(poll: BlogPoll?): String? { if (poll == null) return null return globalJson.encodeToString(poll) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/converts/ContentTabConverter.kt ================================================ package com.zhangke.fread.common.db.converts import androidx.room.TypeConverter import com.zhangke.framework.architect.json.globalJson import com.zhangke.fread.status.model.ContentConfig import kotlinx.serialization.encodeToString class ContentTabConverter { @TypeConverter fun toJsonString(tabList: List): String { if (tabList.isEmpty()) return "" val stringList = tabList.map { globalJson.encodeToString(ContentConfig.ActivityPubContent.ContentTab.serializer(), it) } return globalJson.encodeToString(stringList) } @TypeConverter fun toTabList(jsonText: String): List { if (jsonText.isEmpty()) return emptyList() return globalJson.decodeFromString>(jsonText).map { globalJson.decodeFromString( deserializer = ContentConfig.ActivityPubContent.ContentTab.serializer(), string = it, ) } } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/converts/ContentTypeConverter.kt ================================================ package com.zhangke.fread.common.db.converts import androidx.room.TypeConverter import com.zhangke.fread.status.model.ContentType class ContentTypeConverter { @TypeConverter fun fromString(text: String): ContentType { return ContentType.valueOf(text) } @TypeConverter fun toString(type: ContentType): String { return type.name } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/converts/FormalBaseUrlConverter.kt ================================================ package com.zhangke.fread.common.db.converts import androidx.room.TypeConverter import com.zhangke.framework.network.FormalBaseUrl class FormalBaseUrlConverter { @TypeConverter fun fromString(text: String?): FormalBaseUrl? { text ?: return null return FormalBaseUrl.parse(text)!! } @TypeConverter fun toString(baseUrl: FormalBaseUrl?): String? { baseUrl ?: return null return baseUrl.toString() } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/converts/FormalUriConverter.kt ================================================ package com.zhangke.fread.common.db.converts import androidx.room.TypeConverter import com.zhangke.fread.status.uri.FormalUri class FormalUriConverter { @TypeConverter fun convertToString(uri: FormalUri): String { return uri.toString() } @TypeConverter fun convertToUri(uri: String): FormalUri { return FormalUri.from(uri)!! } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/converts/FreadContentConverter.kt ================================================ package com.zhangke.fread.common.db.converts import androidx.room.TypeConverter import com.zhangke.framework.architect.json.fromJson import com.zhangke.framework.architect.json.globalJson import com.zhangke.fread.status.model.FreadContent import kotlinx.serialization.encodeToString class FreadContentConverter { @TypeConverter fun fromJsonText(text: String): FreadContent { return globalJson.fromJson(text) } @TypeConverter fun toJsonText(content: FreadContent): String { return globalJson.encodeToString(content) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/converts/PlatformLocatorConverter.kt ================================================ package com.zhangke.fread.common.db.converts import androidx.room.TypeConverter import com.zhangke.fread.status.model.PlatformLocator import kotlinx.serialization.json.Json class PlatformLocatorConverter { @TypeConverter fun fromString(string: String): PlatformLocator { return Json.decodeFromString(PlatformLocator.serializer(), string) } @TypeConverter fun toString(locator: PlatformLocator): String { return Json.encodeToString(PlatformLocator.serializer(), locator) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/converts/StatusConverter.kt ================================================ package com.zhangke.fread.common.db.converts import androidx.room.TypeConverter import com.zhangke.fread.status.status.model.Status import kotlinx.serialization.json.Json class StatusConverter { @TypeConverter fun fromString(string: String?): Status? { string ?: return null return Json.decodeFromString(Status.serializer(), string) } @TypeConverter fun toString(status: Status?): String? { status ?: return null return Json.encodeToString(Status.serializer(), status) } } class NonNullStatusConverter { @TypeConverter fun fromString(string: String): Status { return Json.decodeFromString(Status.serializer(), string) } @TypeConverter fun toString(status: Status): String { return Json.encodeToString(Status.serializer(), status) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/converts/StatusNotificationConverter.kt ================================================ package com.zhangke.fread.common.db.converts import androidx.room.TypeConverter import com.zhangke.framework.architect.json.globalJson import com.zhangke.fread.status.notification.StatusNotification import kotlinx.serialization.serializer class StatusNotificationConverter { @TypeConverter fun convertToNotification(jsonString: String): StatusNotification { return globalJson.decodeFromString(serializer(), jsonString) } @TypeConverter fun convertToString(notification: StatusNotification): String { return globalJson.encodeToString(serializer(), notification) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/converts/StatusProviderUriListConverter.kt ================================================ package com.zhangke.fread.common.db.converts import androidx.room.TypeConverter import com.zhangke.framework.architect.json.globalJson import com.zhangke.fread.status.uri.FormalUri import kotlinx.serialization.encodeToString class StatusProviderUriListConverter { @TypeConverter fun fromString(text: String?): List? { text ?: return null val stringList: List = globalJson.decodeFromString(text) return stringList.map(::stringToUri) } @TypeConverter fun toString(uriList: List?): String? { uriList ?: return null val stringList = uriList.map(::uriToString) return globalJson.encodeToString(stringList) } private fun stringToUri(string: String): FormalUri { return FormalUri.from(string)!! } private fun uriToString(uri: FormalUri): String { return uri.toString() } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/converts/StatusUiStateConverter.kt ================================================ package com.zhangke.fread.common.db.converts import androidx.room.TypeConverter import com.zhangke.fread.status.model.StatusUiState import kotlinx.serialization.json.Json class StatusUiStateConverter { @TypeConverter fun convertToText(status: StatusUiState): String { return Json.encodeToString(StatusUiState.serializer(), status) } @TypeConverter fun convertToStatus(text: String): StatusUiState { return Json.decodeFromString(StatusUiState.serializer(), text) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/db/old/OldFreadContentDatabase.kt ================================================ package com.zhangke.fread.common.db.old import androidx.room.ConstructedBy import androidx.room.Dao import androidx.room.Database import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.RoomDatabase import androidx.room.RoomDatabaseConstructor import androidx.room.TypeConverters import com.zhangke.fread.common.db.converts.FreadContentConverter import com.zhangke.fread.status.model.FreadContent import kotlinx.coroutines.flow.Flow private const val DB_VERSION = 1 private const val TABLE_NAME = "fread_content" @Entity(tableName = TABLE_NAME) data class OldFreadContentEntity( @PrimaryKey val id: String, val content: FreadContent, ) @Dao interface OldFreadContentDao { @Query("SELECT * FROM $TABLE_NAME") fun queryAllFlow(): Flow> @Query("SELECT * FROM $TABLE_NAME WHERE id = :id") fun queryFlow(id: String): Flow @Query("SELECT * FROM $TABLE_NAME") suspend fun queryAll(): List @Query("SELECT * FROM $TABLE_NAME WHERE id = :id") suspend fun query(id: String): OldFreadContentEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(content: OldFreadContentEntity) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(list: List) @Query("DELETE FROM $TABLE_NAME WHERE id = :id") suspend fun delete(id: String) } @TypeConverters( FreadContentConverter::class ) @Database( entities = [OldFreadContentEntity::class], version = DB_VERSION, exportSchema = false, ) @ConstructedBy(OldFreadContentDatabaseConstructor::class) abstract class OldFreadContentDatabase : RoomDatabase() { abstract fun contentDao(): OldFreadContentDao companion object { const val DB_NAME = "fread_content.db" } } // The Room compiler generates the `actual` implementations. @Suppress("NO_ACTUAL_FOR_EXPECT") expect object OldFreadContentDatabaseConstructor : RoomDatabaseConstructor { override fun initialize(): OldFreadContentDatabase } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/deeplink/ExternalInputHandler.kt ================================================ package com.zhangke.fread.common.deeplink import com.zhangke.framework.network.SimpleUri import com.zhangke.fread.common.action.ComposableActions import com.zhangke.fread.common.action.RouteAction import com.zhangke.fread.common.utils.GlobalScreenNavigation import com.zhangke.krouter.KRouter import kotlinx.coroutines.delay object ExternalInputHandler { suspend fun handle(text: String) { delay(500) // delay for waiting page resumed val fixedText = ExternalInputParser.parseExternalText(text) val uri = SimpleUri.parse(fixedText) if (uri == null || uri.host.isNullOrEmpty()) { // goto publish screen GlobalScreenNavigation.navigate(SelectAccountForPublishScreenKey(fixedText)) } else { val action = KRouter.route(fixedText) if (action?.execute() == true) return ComposableActions.post(fixedText) } } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/deeplink/ExternalInputParser.kt ================================================ package com.zhangke.fread.common.deeplink import com.eygraber.uri.Uri object ExternalInputParser { /** * Parsing text for external input, such as from Chrome. * “Copied Text” * http://example.com/#:~:text=Copied%20Text */ fun parseExternalText(text: String): String { if (text.isEmpty() || text.isBlank()) return text if (!text.contains("http")) return text.trim() if (!text.startsWith('"')) return text.trim() val endIndex = text.indexOf('"', 1) if (endIndex == -1) return text.trim() val extractedText = text.substring(1, endIndex).trim() if (endIndex == text.lastIndex) return extractedText val urlText = text.substring(endIndex + 1, text.length).trim() val url = Uri.parseOrNull(urlText) ?: return text.trim() if (url.fragment?.contains(extractedText) == true) { return extractedText } return text.trim() } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/deeplink/SelectAccountScreen.kt ================================================ package com.zhangke.fread.common.deeplink import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.LoadableState import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.requireSuccessData import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.popIfNotRoot import com.zhangke.fread.common.composable.SelectableAccount import com.zhangke.fread.common.utils.GlobalScreenNavigation import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.account.LoggedAccount import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable data class SelectAccountForPublishScreenKey(val text: String) : NavKey @Composable fun SelectAccountForPublishScreen(viewModel: SelectAccountForPublishViewModel) { val loadingAccountList by viewModel.loggedAccounts.collectAsState() val navigator = LocalNavBackStack.currentOrThrow ConsumeFlow(viewModel.openScreenFlow) { navigator.popIfNotRoot() GlobalScreenNavigation.navigate(it) } ConsumeFlow(viewModel.finishScreenFlow) { navigator.popIfNotRoot() } Box( modifier = Modifier, contentAlignment = Alignment.Center, ) { when (loadingAccountList) { is LoadableState.Loading -> { Surface( modifier = Modifier, shape = RoundedCornerShape(16.dp), ) { CircularProgressIndicator( modifier = Modifier .padding(vertical = 24.dp, horizontal = 64.dp) .size(80.dp) ) } } is LoadableState.Success -> { val accountList = loadingAccountList.requireSuccessData() Surface( shape = RoundedCornerShape(16.dp), shadowElevation = 6.dp, ) { Column( modifier = Modifier.fillMaxWidth() .padding(16.dp) .verticalScroll(rememberScrollState()), ) { Text( modifier = Modifier, text = stringResource(LocalizedString.statusUiSwitchAccountDialogTitle), style = MaterialTheme.typography.titleMedium .copy(fontWeight = FontWeight.SemiBold), ) for (account in accountList) { Spacer(modifier = Modifier.height(16.dp)) SelectableAccount( account = account, onClick = { viewModel.onAccountSelected(it) }, ) } } } } else -> { navigator.popIfNotRoot() } } } } class SelectAccountForPublishViewModel( private val statusProvider: StatusProvider, private val text: String, ) : ViewModel() { private val _loggedAccounts = MutableStateFlow>>(LoadableState.idle()) val loggedAccounts = _loggedAccounts.asStateFlow() private val _openScreenFlow = MutableSharedFlow() val openScreenFlow = _openScreenFlow.asSharedFlow() private val _finishScreenFlow = MutableSharedFlow() val finishScreenFlow = _finishScreenFlow.asSharedFlow() init { launchInViewModel { _loggedAccounts.emit(LoadableState.loading()) val accounts = statusProvider.accountManager.getAllLoggedAccount() if (accounts.isEmpty()) { _finishScreenFlow.emit(Unit) } else if (accounts.size == 1) { onAccountSelected(accounts.first()) } else { _loggedAccounts.emit(LoadableState.success(accounts)) } } } fun onAccountSelected(account: LoggedAccount) { statusProvider.screenProvider .getPublishScreen(account, text) ?.let { launchInViewModel { _openScreenFlow.emit(it) } } } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/deeplink/SelectedContentSwitcher.kt ================================================ package com.zhangke.fread.common.deeplink import com.zhangke.fread.status.model.FreadContent import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow class SelectedContentSwitcher () { private val _selectedContentFlow = MutableSharedFlow(1) val selectedContentFlow get() = _selectedContentFlow.asSharedFlow() suspend fun switchToContent(content: FreadContent) { _selectedContentFlow.emit(content) } fun resetReplayCache() { _selectedContentFlow.resetReplayCache() } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/di/ApplicationCoroutineScope.kt ================================================ package com.zhangke.fread.common.di typealias ApplicationCoroutineScope = kotlinx.coroutines.CoroutineScope ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/feeds/model/RefreshResult.kt ================================================ package com.zhangke.fread.common.feeds.model import com.zhangke.fread.status.model.StatusUiState data class RefreshResult( val newStatus: List, val deletedStatus: List, val useOldData: Boolean = true, ) { companion object { val EMPTY = RefreshResult(emptyList(), emptyList()) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/handler/TextHandler.kt ================================================ package com.zhangke.fread.common.handler import androidx.compose.runtime.staticCompositionLocalOf expect class TextHandler { val packageName: String val versionName: String val versionCode: String fun copyText(text: String) fun shareUrl(url: String, text: String) fun openSendEmail() fun openAppMarket() } val LocalTextHandler = staticCompositionLocalOf { error("No TextHandler provided") } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/language/LanguageHelper.kt ================================================ package com.zhangke.fread.common.language import androidx.compose.runtime.staticCompositionLocalOf internal const val LOCAL_KEY_LANGUAGE = "app_language_code" @Deprecated("Use LanguageCode directly") enum class LanguageSettingType(val value: Int) { CN(1), EN(2), SYSTEM(3), ; } expect class ActivityLanguageHelper { val currentLanguage: LanguageSettingItem fun initialize() fun setLanguage(item: LanguageSettingItem) } val LocalActivityLanguageHelper = staticCompositionLocalOf { error("No ActivityLanguageHelper provided") } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/language/LanguageSettingItem.kt ================================================ package com.zhangke.fread.common.language import androidx.compose.runtime.Composable import com.zhangke.fread.localization.LanguageCode import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.localization.displayName import org.jetbrains.compose.resources.stringResource sealed interface LanguageSettingItem { companion object { val items: List get() = buildList { add(FollowSystem) LanguageCode.entries.forEach { add(Language(it)) } } fun fromLocalId(id: String): LanguageSettingItem? { return if (id == FollowSystem.LOCAL_ID) { FollowSystem } else { LanguageCode.fromCode(id)?.let { Language(it) } } } } val localId: String @Composable fun getDisplayName(): String data object FollowSystem : LanguageSettingItem { const val LOCAL_ID = "FOLLOW_SYSTEM" override val localId: String = LOCAL_ID @Composable override fun getDisplayName(): String { return stringResource(LocalizedString.profileSettingLanguageSystem) } } data class Language(val code: LanguageCode) : LanguageSettingItem { override val localId: String = code.code @Composable override fun getDisplayName(): String { return code.displayName } } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/mixed/MixedStatusRepo.kt ================================================ package com.zhangke.fread.common.mixed import com.zhangke.fread.common.db.MixedStatusDatabases import com.zhangke.fread.common.db.MixedStatusEntity import com.zhangke.fread.common.status.StatusConfigurationDefault import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.content.MixedContent import com.zhangke.fread.status.model.PagedData import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.uri.FormalUri import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.supervisorScope class MixedStatusRepo ( private val statusProvider: StatusProvider, mixedStatusDatabases: MixedStatusDatabases, ) { private val mixedStatusDao = mixedStatusDatabases.mixedStatusDao() fun getLocalStatusFlow(content: MixedContent): Flow> { return flow { mixedStatusDao.queryFlow(content.sourceUriList) .collect { emit(calculateDisplayList(it)) } } } private fun calculateDisplayList(statusList: List): List { val endingList = getAllCrackEndingEntity(statusList) if (endingList.isEmpty()) return statusList.map { it.status } return statusList.subListOrNot(0, statusList.indexOf(endingList.first()) + 1) .map { it.status } } suspend fun refresh(content: MixedContent): Result { if (content.sourceUriList.isEmpty()) return Result.success(Unit) val statusResolver = statusProvider.statusResolver val allResult = supervisorScope { content.sourceUriList.map { sourceUri -> async { sourceUri to statusResolver.getStatusList( uri = sourceUri, limit = StatusConfigurationDefault.config.loadFromServerLimit, ) } } }.awaitAll() if (allResult.all { it.second.isFailure }) { return Result.failure(allResult.first().second.exceptionOrNull()!!) } allResult.mapNotNull { (sourceUri, result) -> result.getOrNull()?.toEntityList(sourceUri) }.flatten().let { mixedStatusDao.deleteStatusOfSources(content.sourceUriList) mixedStatusDao.insertStatus(it) } return Result.success(Unit) } suspend fun loadMoreStatus( content: MixedContent, ): Result { val statusList = mixedStatusDao.queryAll(content.sourceUriList) if (statusList.isEmpty()) return Result.success(Unit) val endingList = getAllCrackEndingEntity(statusList) if (endingList.isEmpty()) return Result.success(Unit) val allResult = supervisorScope { endingList.take(3).map { entity -> async { entity.sourceUri to statusProvider.statusResolver.getStatusList( uri = entity.sourceUri, limit = StatusConfigurationDefault.config.loadFromServerLimit, maxId = entity.cursor, ) } } }.awaitAll() if (allResult.all { it.second.isFailure }) { return Result.failure(allResult.first().second.exceptionOrNull()!!) } allResult.mapNotNull { (sourceUri, result) -> result.getOrNull()?.toEntityList(sourceUri) }.flatten().let { mixedStatusDao.insertStatus(it) } return Result.success(Unit) } suspend fun updateStatus(status: StatusUiState) { mixedStatusDao.queryByStatusId(status.status.id) .map { it.copy(status = status) } .let { mixedStatusDao.insertStatus(it) } } suspend fun deleteStatus(statusId: String) { val entityList = mixedStatusDao.queryByStatusId(statusId) if (entityList.isEmpty()) return val newStatusList = mutableListOf() entityList.groupBy { it.sourceUri } .filter { it.value.isNotEmpty() } .map { it.value.sortedByDescending { item -> item.createAt } } .forEach { statusList -> val lastEntity = statusList.last() if (lastEntity.statusId == statusId) { var list = statusList.subList(0, statusList.lastIndex) if (!lastEntity.cursor.isNullOrEmpty()) { list = list.mapIndexed { index, entity -> if (index == list.lastIndex) { entity.copy(cursor = lastEntity.cursor) } else { entity } } } newStatusList.addAll(list) } else { newStatusList.addAll(statusList) } } mixedStatusDao.insertStatus(newStatusList) } /** * 获取每个 Source 下对应的帖子列表中最早的那个帖子,如果这个帖子可以加载更多(包含 cursor)。 */ private fun getAllCrackEndingEntity(list: List): List { val endingList = mutableListOf() val groupedList = list.groupBy { it.sourceUri } groupedList.forEach { (_, entities) -> entities.minByOrNull { it.createAt } ?.takeIf { !it.cursor.isNullOrEmpty() } ?.let { endingList += it } } return endingList.sortedByDescending { it.createAt } } private fun PagedData.toEntityList(sourceUri: FormalUri): List { return this.list.sortedByDescending { it.status.createAt.epochMillis } .mapIndexed { index, item -> MixedStatusEntity( statusId = item.status.id, status = item, createAt = item.status.createAt.epochMillis, sourceUri = sourceUri, cursor = if (index == this.list.lastIndex) this.cursor else null, ) } } private fun List.subListOrNot(fromIndex: Int, toIndex: Int): List { if (fromIndex < 0 || fromIndex > toIndex || fromIndex > size) return this if (toIndex < 0 || toIndex > size) return this return subList(fromIndex, toIndex) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/onboarding/OnboardingComponent.kt ================================================ package com.zhangke.fread.common.onboarding import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow class OnboardingComponent () { private val _onboardingFinishedFlow = MutableSharedFlow(1) val onboardingFinishedFlow = _onboardingFinishedFlow.asSharedFlow() suspend fun onboardingSuccess() { _onboardingFinishedFlow.emit(Unit) } fun clearState() { _onboardingFinishedFlow.resetReplayCache() } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/page/BasePagerTabHookManager.kt ================================================ package com.zhangke.fread.common.page import androidx.compose.runtime.Composable object BasePagerTabHookManager { private val _hookList = mutableListOf() val hookList: List get() = _hookList init { _hookList.addAll(findBasePagerTabImplementers()) } } interface BasePagerTabHook { @Composable fun HookContent() } internal expect fun findBasePagerTabImplementers(): List ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/publish/PublishPostManager.kt ================================================ package com.zhangke.fread.common.publish class PublishPostManager () { fun publish(){ } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/push/IPushManager.kt ================================================ package com.zhangke.fread.common.push import kotlinx.serialization.json.JsonObject interface IPushManager { fun getEndpointUrl(encodedAccountId: String, deviceId: String): String suspend fun registerToRelay(accountId: String, deviceId: String): Result suspend fun unregisterToRelay(accountId: String, deviceId: String): Result } interface PushMessageReceiver { fun onReceiveNewMessage(message: PushMessage) } data class PushMessage( val encodedAccountId: String, val messageData: String, val cryptoKey: String, val contentEncoding: String, val encryption: String, ) ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/repo/LinkPreviewCardRepo.kt ================================================ package com.zhangke.fread.common.repo import com.zhangke.framework.architect.http.sharedHttpClient import com.zhangke.framework.utils.LinkPreviewInfo import com.zhangke.framework.utils.LinkPreviewUtils import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.http.takeFrom class LinkPreviewCardRepo { private val urlToInfoMap = mutableMapOf() suspend fun fetchPreviewInfo(url: String): Result { urlToInfoMap[url]?.let { return Result.success(it) } val html = sharedHttpClient.get { url { takeFrom(url) } }.body() val info = LinkPreviewUtils.fetchPreviewInfo(url, html) ?: return Result.failure( IllegalStateException("Failed to fetch link preview info") ) urlToInfoMap[url] = info return Result.success(info) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/resources/ProtocolsSymbol.kt ================================================ package com.zhangke.fread.common.resources import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.RssFeed import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import com.zhangke.fread.common.daynight.LocalActivityDayNightHelper import com.zhangke.fread.commonbiz.Res import com.zhangke.fread.commonbiz.bluesky_logo import com.zhangke.fread.commonbiz.mastodon_black_text import com.zhangke.fread.commonbiz.mastodon_logo import com.zhangke.fread.commonbiz.mastodon_white_text import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.StatusProviderProtocol import com.zhangke.fread.status.model.isActivityPub import com.zhangke.fread.status.model.isBluesky import com.zhangke.fread.status.model.isRss import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource val StatusProviderProtocol.logo: ImageVector @Composable get() { return when { isActivityPub -> mastodonLogo() isRss -> rssLogo() isBluesky -> blueskyLogo() else -> rssLogo() } } @Composable fun PlatformLogo( modifier: Modifier, protocol: StatusProviderProtocol, ) { Box( modifier = modifier, ) { val logo = when { protocol.isBluesky -> blueskyLogo() protocol.isActivityPub -> mastodonLogo() else -> null } if (logo != null) { Image( modifier = Modifier.fillMaxSize(), imageVector = logo, contentDescription = null, ) } } } @Composable fun mastodonName(): String { return stringResource(LocalizedString.mastodonName) } @Composable fun mastodonDescription(): String { return stringResource(LocalizedString.mastodonDescription) } @Composable fun mastodonLogo(): ImageVector { return vectorResource(Res.drawable.mastodon_logo) } @Composable fun mastodonHorizontalLogo(): ImageVector { val night = LocalActivityDayNightHelper.current.dayNightModeFlow.value.isNight return if (night) { vectorResource(Res.drawable.mastodon_white_text) } else { vectorResource(Res.drawable.mastodon_black_text) } } @Composable fun blueskyName(): String { return stringResource(LocalizedString.blueskyName) } @Composable fun blueskyDescription(): String { return stringResource(LocalizedString.blueskyDescription) } @Composable fun blueskyLogo(): ImageVector { return vectorResource(Res.drawable.bluesky_logo) } @Composable fun rssLogo(): ImageVector { return Icons.Default.RssFeed } @Composable fun mixedName(): String { return stringResource(LocalizedString.mixedContentName) } @Composable fun mixedDescription(): String { return stringResource(LocalizedString.mixedContentDescription) } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/review/DefaultAppStoreReviewer.kt ================================================ package com.zhangke.fread.common.review interface DefaultAppStoreReviewer { fun showAppStoreReviewPopup( onReviewSuccess: () -> Unit, onReviewCancel: () -> Unit, ) } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/review/FreadReviewManager.kt ================================================ package com.zhangke.fread.common.review import androidx.compose.runtime.staticCompositionLocalOf import com.zhangke.fread.common.config.LocalConfigManager import com.zhangke.fread.common.di.ApplicationCoroutineScope import com.zhangke.fread.common.utils.getCurrentTimeMillis import com.zhangke.krouter.KRouter import kotlinx.coroutines.launch import kotlin.time.Duration import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds class FreadReviewManager ( private val localConfigManager: LocalConfigManager, private val applicationCoroutineScope: ApplicationCoroutineScope, ) { companion object { private const val LOCAL_KEY_LATEST_SHOW_TIME = "latestShowPlayReviewTime" private const val LOCAL_KEY_REVIEWED = "playReviewed" private const val LOCAL_KET_REVIEW_POP_COUNT = "playReviewPopCount" private val maxInternal = 90.days } fun trigger(forceShow: Boolean = false) { if (forceShow) { showPlayReviewPopup() } else { applicationCoroutineScope.launch { maybeShowPlayReviewPopup() } } } private suspend fun maybeShowPlayReviewPopup() { val reviewed = getReviewed() if (reviewed) return val count = getPlayReviewPopCount() val latestShowTime = getLatestShowPlayReviewTime() if (count == 0 && latestShowTime == 0) { // mark first launching app time setLatestShowPlayReviewTime() return } val duration = getCurrentTimeMillis().milliseconds - latestShowTime.milliseconds if (isDurationOvertime(duration, count)) { showPlayReviewPopup() } } private fun showPlayReviewPopup() { KRouter.getServices().firstOrNull()?.showAppStoreReviewPopup( onReviewSuccess = ::onReviewSuccess, onReviewCancel = ::onReviewCancel, ) } private fun isDurationOvertime(duration: Duration, count: Int): Boolean { if (duration > maxInternal) return true if (count == 0) return duration >= 3.days if (count == 1) return duration > 10.days if (count == 2) return duration > 30.days return false } internal fun onReviewSuccess() { applicationCoroutineScope.launch { setReviewed() } } internal fun onReviewCancel() { applicationCoroutineScope.launch { increasePlayReviewPopCount() setLatestShowPlayReviewTime() } } private suspend fun setReviewed() { localConfigManager.putBoolean(LOCAL_KEY_REVIEWED, true) } private suspend fun getReviewed(): Boolean { return localConfigManager.getBoolean(LOCAL_KEY_REVIEWED) ?: false } private suspend fun increasePlayReviewPopCount() { val count = getPlayReviewPopCount() + 1 localConfigManager.putInt(LOCAL_KET_REVIEW_POP_COUNT, count) } private suspend fun getPlayReviewPopCount(): Int { return localConfigManager.getInt(LOCAL_KET_REVIEW_POP_COUNT) ?: 0 } private suspend fun setLatestShowPlayReviewTime() { val time = (getCurrentTimeMillis() / 1000).toInt() localConfigManager.putInt(LOCAL_KEY_LATEST_SHOW_TIME, time) } private suspend fun getLatestShowPlayReviewTime(): Int { return localConfigManager.getInt(LOCAL_KEY_LATEST_SHOW_TIME) ?: 0 } } val LocalFreadReviewManager = staticCompositionLocalOf { error("No FreadReviewManager provided") } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/startup/FeedsRepoModuleStartup.kt ================================================ package com.zhangke.fread.common.startup import com.zhangke.framework.module.ModuleStartup class FeedsRepoModuleStartup ( ) : ModuleStartup { override fun onAppCreate() { } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/startup/FreadConfigModuleStartup.kt ================================================ package com.zhangke.fread.common.startup import com.zhangke.framework.module.ModuleStartup import com.zhangke.fread.common.config.FreadConfigManager import com.zhangke.fread.common.di.ApplicationCoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch class FreadConfigModuleStartup( private val applicationCoroutineScope: ApplicationCoroutineScope, private val freadConfigManager: FreadConfigManager, ) : ModuleStartup { override fun onAppCreate() { applicationCoroutineScope.launch(Dispatchers.IO) { freadConfigManager.initConfig() } } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/startup/StartupManager.kt ================================================ package com.zhangke.fread.common.startup import com.zhangke.framework.module.ModuleStartup class StartupManager ( private val startupList: Set, ) { fun initialize() { startupList.forEach { it.onAppCreate() } } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/status/StatusConfiguration.kt ================================================ package com.zhangke.fread.common.status import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes data class StatusConfiguration( val loadFromServerLimit: Int, val loadFromLocalLimit: Int, val loadFromLocalRedundancies: Int, val autoFetchNewerFeedsInterval: Duration, ) object StatusConfigurationDefault { val config = StatusConfiguration( loadFromServerLimit = 60, loadFromLocalLimit = 100, loadFromLocalRedundancies = 3, autoFetchNewerFeedsInterval = 2.minutes, ) } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/status/StatusIdGenerator.kt ================================================ package com.zhangke.fread.common.status import com.zhangke.fread.status.status.model.Status import com.zhangke.fread.status.uri.FormalUri class StatusIdGenerator () { fun generate(sourceUri: FormalUri, status: Status): String { return "${sourceUri.host}_${sourceUri.path}_${status.id}" } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/status/StatusUpdater.kt ================================================ package com.zhangke.fread.common.status import com.zhangke.fread.status.model.StatusUiState import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow class StatusUpdater () { private val _statusUpdateFlow = MutableSharedFlow() val statusUpdateFlow: SharedFlow get() = _statusUpdateFlow.asSharedFlow() suspend fun update(status: StatusUiState) { _statusUpdateFlow.emit(status) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/status/adapter/ContentConfigAdapter.kt ================================================ package com.zhangke.fread.common.status.adapter import com.zhangke.fread.common.db.ContentConfigEntity import com.zhangke.fread.status.model.ContentConfig import com.zhangke.fread.status.model.ContentType class ContentConfigAdapter () { fun toContentConfig(entity: ContentConfigEntity): ContentConfig { return when (entity.type) { ContentType.MIXED -> ContentConfig.MixedContent( id = entity.id, order = entity.order, name = entity.name, sourceUriList = entity.sourceUriList!!, ) ContentType.ACTIVITY_PUB -> ContentConfig.ActivityPubContent( id = entity.id, order = entity.order, name = entity.name, baseUrl = entity.baseUrl!!, showingTabList = entity.showingTabList, hiddenTabList = entity.hiddenTabList, ) ContentType.BLUESKY -> ContentConfig.BlueskyContent( id = entity.id, order = entity.order, name = entity.name, baseUrl = entity.baseUrl!!, tabList = emptyList(), ) } } fun toEntity(config: ContentConfig): ContentConfigEntity { return when (config) { is ContentConfig.MixedContent -> ContentConfigEntity( id = config.id, order = config.order, name = config.name, type = ContentType.MIXED, sourceUriList = config.sourceUriList, baseUrl = null, showingTabList = emptyList(), hiddenTabList = emptyList() ) is ContentConfig.ActivityPubContent -> ContentConfigEntity( id = config.id, name = config.name, order = config.order, type = ContentType.ACTIVITY_PUB, sourceUriList = null, baseUrl = config.baseUrl, showingTabList = config.showingTabList, hiddenTabList = config.hiddenTabList, ) is ContentConfig.BlueskyContent -> ContentConfigEntity( id = config.id, name = config.name, order = config.order, type = ContentType.BLUESKY, sourceUriList = null, baseUrl = config.baseUrl, showingTabList = emptyList(), hiddenTabList = emptyList(), ) } } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/status/model/SearchResultUiState.kt ================================================ package com.zhangke.fread.common.status.model import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.Hashtag import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.platform.BlogPlatform sealed interface SearchResultUiState { data class Author(val locator: PlatformLocator, val author: BlogAuthor) : SearchResultUiState data class Platform(val locator: PlatformLocator, val platform: BlogPlatform) : SearchResultUiState data class SearchedStatus(val status: StatusUiState) : SearchResultUiState data class SearchedHashtag(val locator: PlatformLocator, val hashtag: Hashtag) : SearchResultUiState } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/status/usecase/FormatStatusDisplayTimeUseCase.kt ================================================ package com.zhangke.fread.common.status.usecase import com.zhangke.fread.status.utils.DateTimeFormatter import com.zhangke.fread.status.utils.DatetimeFormatConfig import com.zhangke.fread.status.utils.defaultFormatConfig class FormatStatusDisplayTimeUseCase ( ) { suspend operator fun invoke( datetime: Long, ): String { return invoke( datetime, config = defaultFormatConfig(), ) } operator fun invoke( datetime: Long, config: DatetimeFormatConfig, ): String { return DateTimeFormatter.format(datetime, config) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/theme/ThemeType.kt ================================================ package com.zhangke.fread.common.theme enum class ThemeType { DEFAULT, SYSTEM_DYNAMIC, } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/update/AppPlatformUpdater.kt ================================================ package com.zhangke.fread.common.update expect class AppPlatformUpdater() { val platformName: String val signingForFDroid: Boolean fun getAppVersionCode(): Long fun triggerUpdate(releaseInfo: AppReleaseInfo) } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/update/AppReleaseInfo.kt ================================================ package com.zhangke.fread.common.update import kotlinx.serialization.Serializable @Serializable data class AppReleaseInfo( val versionCode: Long, val versionName: String, val releaseNote: String, val downloadUrl: String, ) ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/update/AppUpdateManager.kt ================================================ package com.zhangke.fread.common.update import com.zhangke.framework.architect.http.sharedHttpClient import com.zhangke.framework.utils.exceptionOrThrow import com.zhangke.fread.common.config.FreadConfigManager import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.http.takeFrom class AppUpdateManager ( private val configManager: FreadConfigManager, ) { companion object { private const val API_RELEASE = "https://api.fread.xyz/app/release" private const val QUERY_PLATFORM = "platform" } private val platformUpdater = AppPlatformUpdater() val enableAutoCheckUpdate: Boolean get() = !platformUpdater.signingForFDroid suspend fun checkForUpdate(checkIgnoreVersion: Boolean = true): Result> { val releaseInfoResult = getReleaseInfo() if (releaseInfoResult.isFailure) return Result.failure(releaseInfoResult.exceptionOrThrow()) val releaseInfo = releaseInfoResult.getOrThrow() val currentVersion = platformUpdater.getAppVersionCode() if (releaseInfo.versionCode <= currentVersion) { return Result.success(false to releaseInfo) } if (checkIgnoreVersion) { val ignoreUpdateVersion = configManager.getIgnoreUpdateVersion() ?: -1 if (ignoreUpdateVersion >= releaseInfo.versionCode) { // ignore this version return Result.success(false to releaseInfo) } } return Result.success(true to releaseInfo) } suspend fun updateApp(releaseInfo: AppReleaseInfo) { platformUpdater.triggerUpdate(releaseInfo) } suspend fun ignoreVersion(releaseInfo: AppReleaseInfo) { configManager.updateIgnoreUpdateVersion(releaseInfo.versionCode) } private suspend fun getReleaseInfo(): Result { return runCatching { sharedHttpClient.get { url { takeFrom(API_RELEASE) parameter(QUERY_PLATFORM, platformUpdater.platformName) } }.body() } } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/utils/GlobalScreenNavigation.kt ================================================ package com.zhangke.fread.common.utils import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow object GlobalScreenNavigation { private val _openScreenFlow = MutableSharedFlow() val openScreenFlow: SharedFlow get() = _openScreenFlow.asSharedFlow() suspend fun navigate(screen: NavKey) { _openScreenFlow.emit(screen) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/utils/HashtagTextUtils.kt ================================================ package com.zhangke.fread.common.utils import androidx.compose.ui.text.TextRange object HashtagTextUtils { private const val MARK_START = Int.MIN_VALUE private const val MARK_END = Int.MAX_VALUE private val BLUESKY_EXCLUDED_CHARS = setOf('\u00AD', '\u2060', '\u200A', '\u200B', '\u200C', '\u200D', '\u20E2') private val Int.isStartMark: Boolean get() = this == MARK_START private val Int.isEndMark: Boolean get() = this == MARK_END /** * Returns `true` if this character is a Unicode mark. * * Equivalent to testing if the char would be matched by the regular expression * `\p{M}` or `\p{Mark}` (with Unicode enabled). * * Specifically, a character is a Unicode mark if its [category] is one of * [CharCategory.NON_SPACING_MARK], [CharCategory.COMBINING_SPACING_MARK], and * [CharCategory.ENCLOSING_MARK]. */ private fun Char.isMark(): Boolean { return when (this.category) { CharCategory.NON_SPACING_MARK, CharCategory.COMBINING_SPACING_MARK, CharCategory.ENCLOSING_MARK -> true else -> false } } /** * Returns `true` if this character is a Unicode number. * * Equivalent to testing if the char would be matched by the regular expression * `\p{N}` or `\p{Number}` (with Unicode enabled). * * Specifically, a character is a Unicode number if its [category] is one of * [CharCategory.DECIMAL_DIGIT_NUMBER], [CharCategory.LETTER_NUMBER], and * [CharCategory.OTHER_NUMBER]. */ private fun Char.isNumber(): Boolean { return when (this.category) { CharCategory.DECIMAL_DIGIT_NUMBER, CharCategory.LETTER_NUMBER, CharCategory.OTHER_NUMBER -> true else -> false } } /** * Returns `true` if this character is a Unicode symbol. * * Equivalent to testing if the char would be matched by the regular expression * `\p{S}` or `\p{Symbol}` (with Unicode enabled). * * Specifically, a character is a Unicode symbol if its [category] is one of * [CharCategory.MATH_SYMBOL], [CharCategory.CURRENCY_SYMBOL], * [CharCategory.MODIFIER_SYMBOL], and [CharCategory.OTHER_SYMBOL]. */ private fun Char.isSymbol(): Boolean { return when (this.category) { CharCategory.MATH_SYMBOL, CharCategory.CURRENCY_SYMBOL, CharCategory.MODIFIER_SYMBOL, CharCategory.OTHER_SYMBOL -> true else -> false } } /** * @param text The text to search for hashtags * * @param allowHashtagInHashtag If `false`, use a Mastodon-like method to parse hashtags. If * `true`, use a Bluesky-style parsing method. * * @see "commonbiz/common/src/commonTest/kotlin/com/zhangke/fread/common/utils/HashtagTextUtilsTest.kt" * for examples of differences between Mastodon and Bluesky-style hashtag processing */ fun findHashtags(text: String, allowHashtagInHashtag: Boolean = false): List { if (text.isEmpty()) return emptyList() return if (allowHashtagInHashtag) { findHashtagsBlueskyStyle(text) } else { findHashtagsMastodonStyle(text) } } private fun findHashtagsMastodonStyle(text: String): List { val list = mutableListOf() val chars = text.toCharArray() var index = 0 var start = MARK_START var end = MARK_END var prevIsSep = true var hasAlpha = false while (index < text.length) { val char = chars[index] when { char == '#' -> { if (prevIsSep) { start = index hasAlpha = false } else if (!start.isStartMark) { end = index } prevIsSep = true } char.isWhitespace() -> { if (!start.isStartMark && end.isEndMark) { end = index } prevIsSep = true } char.isLetter() || char.isMark() -> { prevIsSep = false hasAlpha = true } char.isNumber() || char.category == CharCategory.CONNECTOR_PUNCTUATION -> { prevIsSep = false } char != '\u00b7' && char != '\u200c' -> { if (!start.isStartMark && end.isEndMark) { end = index } prevIsSep = (char != '/' && char != ')') } } if (!start.isStartMark && !end.isEndMark) { if (hasAlpha) { list += TextRange(start = start, end = end) } start = MARK_START end = MARK_END } index++ } if (index == text.length && !start.isStartMark && end.isEndMark && hasAlpha) { list += TextRange(start = start, end = index) } return list } private fun findHashtagsBlueskyStyle(text: String): List { val list = mutableListOf() val chars = text.toCharArray() var index = 0 var start = MARK_START var end = MARK_END var lastEnd = MARK_END var prevIsSep = true var hasAlpha = false while (index < text.length) { val char = chars[index] when { char == '#' -> { if (prevIsSep) { start = index hasAlpha = false lastEnd = MARK_END } else if (!start.isStartMark) { hasAlpha = false } prevIsSep = false } char.isWhitespace() -> { if (!start.isStartMark && end.isEndMark) { end = if (hasAlpha) index else lastEnd if (end.isEndMark) { start = MARK_START } } prevIsSep = true } BLUESKY_EXCLUDED_CHARS.contains(char) -> { if (!start.isStartMark && end.isEndMark) { end = if (hasAlpha) index else lastEnd if (end.isEndMark) { start = MARK_START } } prevIsSep = false } char.isLetter() || char.isMark() || char.isSymbol() -> { prevIsSep = false hasAlpha = true lastEnd = index + 1 } else -> { prevIsSep = false hasAlpha = false } } if (!start.isStartMark && !end.isEndMark) { list += TextRange(start = start, end = end) start = MARK_START end = MARK_END } index++ } if (index == text.length && !start.isStartMark && end.isEndMark) { end = if (hasAlpha) index else lastEnd if (!end.isEndMark) { list += TextRange(start = start, end = end) } } return list } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/utils/InstantExt.kt ================================================ @file:OptIn(ExperimentalTime::class) package com.zhangke.fread.common.utils import com.zhangke.framework.date.InstantFormater import kotlinx.datetime.Instant import kotlin.time.Clock import kotlin.time.ExperimentalTime fun getCurrentInstant(): Instant { return Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds()) } fun getCurrentTimeMillis(): Long { return Clock.System.now().toEpochMilliseconds() } fun com.zhangke.framework.datetime.Instant.formatDefault(): String { return this.instant.formatDefault() } fun com.zhangke.framework.datetime.Instant.formatDate(): String { return this.instant.formatDate() } fun Instant.formatDefault(): String { return InstantFormater().formatToMediumDate(this) } fun Instant.formatDate(): String { return InstantFormater().formatToMediumDateWithoutTime(this) } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/utils/LinkTextUtils.kt ================================================ package com.zhangke.fread.common.utils import androidx.compose.ui.text.TextRange object LinkTextUtils { private val urlRegex = """ (?:https?://)?(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}(?!\.[A-Za-z0-9-])(?=$|[:/?#]|[^\w-])(?::\d{2,5})?(?:[/?#][^\s'"]*)? """.trimIndent().toRegex() fun findLinks(text: String): List { if (text.isEmpty()) return emptyList() val list = mutableListOf() urlRegex.findAll(text) .forEach { list += TextRange(start = it.range.start, end = it.range.endInclusive + 1) } return list } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/utils/ListStringConverter.kt ================================================ package com.zhangke.fread.common.utils import androidx.room.TypeConverter import com.zhangke.framework.architect.json.globalJson import kotlinx.serialization.encodeToString class ListStringConverter { @TypeConverter fun fromStringList(text: String): List { return globalJson.decodeFromString(text) } @TypeConverter fun toStringList(list: List): String { return globalJson.encodeToString(list) } } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/utils/MediaFileHelper.kt ================================================ package com.zhangke.fread.common.utils import androidx.compose.runtime.staticCompositionLocalOf expect class MediaFileHelper { fun saveImageToGallery(url: String) fun saveVideoToGallery(url: String) } val LocalMediaFileHelper = staticCompositionLocalOf { error("No MediaFileHelper provided") } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/utils/MentionTextUtil.kt ================================================ package com.zhangke.fread.common.utils import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.substring object MentionTextUtil { fun findTypingMentionName(text: TextFieldValue): String? { val range = findTypingMentionRange(text) ?: return null return text.text.substring(range) } fun insertMention(text: TextFieldValue, insertText: String): TextFieldValue { if (insertText.isEmpty()) return text val fixedInsertText = "@$insertText" val range = findTypingMentionRange(text) if (range == null) { val newText = "${text.text} $fixedInsertText " return TextFieldValue(text = newText, TextRange(newText.length)) } else { val finalInsertText = " $fixedInsertText " val newText = text.text.replaceRange( startIndex = range.start, endIndex = range.end, replacement = finalInsertText, ) return TextFieldValue(text = newText, TextRange(range.start + finalInsertText.length)) } } fun findMentionList(text: String): List { if (text.isEmpty()) return emptyList() return TextMentionMatcher().findMentionRanges(text.toCharArray()) } private fun findTypingMentionRange(text: TextFieldValue): TextRange? { if (text.selection.length != 0) return null if (text.selection.start <= 0) return null val chars = text.text.toCharArray() return TypingMentionMatcher().findTypingMentionRange( chars = chars, startIndex = text.selection.start - 1, ) } } class TypingMentionMatcher { companion object { private const val STATE_INIT = 0 /** * scanning @AtomZ@m.cmx.im or @AtomX suffix */ private const val STATE_SCANNING_NAME_OR_HOST = STATE_INIT + 1 /** * scanning @AtomZ@m.cmx.im name part */ private const val STATE_SCANNING_NAME = STATE_SCANNING_NAME_OR_HOST + 1 } fun findTypingMentionRange(chars: CharArray, startIndex: Int): TextRange? { if (startIndex <= 0 || startIndex >= chars.size) return null var state = STATE_INIT var index = startIndex var firstMentionFlagIndex = -1 while (index >= 0) { when (state) { STATE_INIT -> { state = STATE_SCANNING_NAME_OR_HOST } STATE_SCANNING_NAME_OR_HOST -> { if (chars[index].validateWebFinerSymbol) { index-- } else if (chars[index].isMentionFlag) { firstMentionFlagIndex = index index-- state = STATE_SCANNING_NAME } else { return null } } STATE_SCANNING_NAME -> { if (chars[index].validateWebFinerSymbol) { index-- } else if (chars[index].isMentionFlag) { if (firstMentionFlagIndex - index == 1) break return TextRange(index, startIndex + 1) } else { break } } } } if (state == STATE_SCANNING_NAME) { return TextRange(firstMentionFlagIndex, startIndex + 1) } return null } } class TextMentionMatcher { companion object { private const val STATE_SCANNING_FIRST_MENTION_FLAG = 0 private const val STATE_SCANNING_NAME = STATE_SCANNING_FIRST_MENTION_FLAG + 1 private const val STATE_SCANNING_HOST = STATE_SCANNING_NAME + 1 } fun findMentionRanges(chars: CharArray): List { if (chars.isEmpty()) return emptyList() val list = mutableListOf() var state = STATE_SCANNING_FIRST_MENTION_FLAG var index = 0 var nameMentionFlagIndex = -1 while (index < chars.size) { val char = chars[index] when (state) { STATE_SCANNING_FIRST_MENTION_FLAG -> { if (char.isMentionFlag) { nameMentionFlagIndex = index state = STATE_SCANNING_NAME } index++ } STATE_SCANNING_NAME -> { if (char.isMentionFlag) { state = STATE_SCANNING_HOST } else if (!char.validateWebFinerSymbol) { list += TextRange(nameMentionFlagIndex, index) nameMentionFlagIndex = -1 state = STATE_SCANNING_FIRST_MENTION_FLAG } index++ } STATE_SCANNING_HOST -> { if (!char.validateWebFinerSymbol) { if (index - nameMentionFlagIndex > 1) { list += TextRange(nameMentionFlagIndex, index) } state = STATE_SCANNING_FIRST_MENTION_FLAG } index++ } } } if (state == STATE_SCANNING_HOST && index - nameMentionFlagIndex > 1) { list += TextRange(nameMentionFlagIndex, index) } if (state == STATE_SCANNING_NAME){ list += TextRange(nameMentionFlagIndex, index) } return list } } private val Char.isMentionFlag: Boolean get() = this == '@' private val validateWebFingerSymbols = arrayOf( '-', '.', '_', '+', '%', '!', '=', '~', '*' ) private val Char.validateWebFinerSymbol: Boolean get() { return this.isLetterOrDigit() || this in validateWebFingerSymbols } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/utils/PlatformUriHelper.kt ================================================ package com.zhangke.fread.common.utils import androidx.compose.runtime.staticCompositionLocalOf import com.zhangke.framework.utils.ContentProviderFile import com.zhangke.framework.utils.PlatformUri expect class PlatformUriHelper { suspend fun read(uri: PlatformUri): ContentProviderFile? suspend fun readBytes(uri: PlatformUri): ByteArray? fun queryFileName(uri: PlatformUri): String? } val LocalPlatformUriHelper = staticCompositionLocalOf { error("No PlatformUriHelper provided") } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/utils/RandomIdGenerator.kt ================================================ package com.zhangke.fread.common.utils expect class RandomIdGenerator() { fun generateId(): String } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/utils/StorageHelper.kt ================================================ package com.zhangke.fread.common.utils import okio.Path expect class StorageHelper { val cacheDir: Path } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/utils/ToastHelper.kt ================================================ package com.zhangke.fread.common.utils import androidx.compose.runtime.staticCompositionLocalOf expect class ToastHelper { fun showToast(content: String) } val LocalToastHelper = staticCompositionLocalOf { error("No ToastHelper provided") } ================================================ FILE: commonbiz/common/src/commonMain/kotlin/com/zhangke/fread/common/utils/WebFingerConverter.kt ================================================ package com.zhangke.fread.common.utils import androidx.room.TypeConverter import com.zhangke.framework.utils.WebFinger class WebFingerConverter { @TypeConverter fun fromWebFinger(webFinger: WebFinger): String { return webFinger.toString() } @TypeConverter fun toWebFinger(text: String): WebFinger { return WebFinger.create(text)!! } } ================================================ FILE: commonbiz/common/src/commonTest/kotlin/com/zhangke/fread/common/status/utils/createStatus.kt ================================================ package com.zhangke.fread.common.status.utils import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.common.utils.createActivityPubUserUri import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.blog.BlogMedia import com.zhangke.fread.status.blog.BlogPoll import com.zhangke.fread.status.blog.PostingApplication import com.zhangke.fread.status.model.StatusProviderProtocol import com.zhangke.fread.status.model.StatusVisibility import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.status.model.Status import com.zhangke.fread.status.uri.FormalUri import kotlinx.datetime.Clock import kotlinx.datetime.Instant fun createStatus( id: String = "1", title: String = "title", author: BlogAuthor = createBlogAuthor(), content: String = "content", date: Instant = Clock.System.now(), forwardCount: Int? = null, likeCount: Int? = null, repliesCount: Int? = null, sensitive: Boolean = false, spoilerText: String = "", mediaList: List = emptyList(), pinned: Boolean = false, poll: BlogPoll? = null, visibility: StatusVisibility = StatusVisibility.PUBLIC, card: PreviewCard? = null, isSelf: Boolean = false, supportTranslate: Boolean = false, editedAt: Instant? = null, application: PostingApplication? = null, ): Status { return Status.NewBlog( blog = Blog( id = id, author = author, description = null, content = content, title = title, createAt = com.zhangke.framework.datetime.Instant(date), emojis = emptyList(), forwardCount = forwardCount, likeCount = likeCount, repliesCount = repliesCount, url = "", sensitive = sensitive, spoilerText = spoilerText, mediaList = mediaList, poll = poll, platform = createBlogPlatform(), mentions = emptyList(), tags = emptyList(), card = null, supportTranslate = false, pinned = pinned, visibility = visibility, isSelf = isSelf, editedAt = null, application = application, ), supportInteraction = emptyList() ) } fun createBlogPlatform( uri: String = "https://example.com", name: String = "example", description: String = "description", baseUrl: FormalBaseUrl = FormalBaseUrl.build("https", "example.com"), protocol: StatusProviderProtocol = mockStatusProviderProtocol(), thumbnail: String? = null, ): BlogPlatform { return BlogPlatform( uri = uri, name = name, description = description, baseUrl = baseUrl, protocol = protocol, thumbnail = thumbnail ) } fun mockStatusProviderProtocol(): StatusProviderProtocol { return StatusProviderProtocol( id = "id", name = "example", ) } fun createBlogAuthor( uri: FormalUri = createActivityPubUserUri(), webFinger: WebFinger = WebFinger.create("@AtomZ@m.cmx.im")!!, name: String = "Atom", description: String = "mock desc", avatar: String? = null, ) = BlogAuthor( uri = uri, webFinger = webFinger, name = name, description = description, avatar = avatar, emojis = emptyList(), ) ================================================ FILE: commonbiz/common/src/commonTest/kotlin/com/zhangke/fread/common/utils/DateTimeFormatterTest.kt ================================================ package com.zhangke.fread.common.utils import com.zhangke.fread.status.utils.DateTimeFormatter import com.zhangke.fread.status.utils.DatetimeFormatConfig import kotlinx.datetime.Clock import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds class DateTimeFormatterTest { private lateinit var config: DatetimeFormatConfig @BeforeTest fun initConfig() { config = DatetimeFormatConfig( agoPrefix = "", agoSuffix = "前", day = "天", hour = "小时", minutes = "分钟", second = "秒", ) } @Test fun testFormat() { val datetime = Clock.System.now() assertEquals("10 秒前", DateTimeFormatter.format(datetime.minus(10.seconds).toEpochMilliseconds(), config)) assertEquals("10 分钟前", DateTimeFormatter.format(datetime.minus(10.minutes).toEpochMilliseconds(), config)) assertEquals("10 小时前", DateTimeFormatter.format(datetime.minus(10.hours).toEpochMilliseconds(), config)) assertEquals("1 天前", DateTimeFormatter.format(datetime.minus(1.days).toEpochMilliseconds(), config)) assertEquals("2 天前", DateTimeFormatter.format(datetime.minus(2.days).toEpochMilliseconds(), config)) assertEquals("2 天前", DateTimeFormatter.format(datetime.minus(2.9.days).toEpochMilliseconds(), config)) assertEquals("2024-09-13", DateTimeFormatter.format(1726201813038, config)) } @Test fun testFormatWithPrefix() { val datetime = Clock.System.now() val config = DatetimeFormatConfig( agoPrefix = "Hace ", agoSuffix = "", day = "día", hour = "hora", minutes = "min", second = "seg", ) assertEquals("Hace 10 seg", DateTimeFormatter.format(datetime.minus(10.seconds).toEpochMilliseconds(), config)) assertEquals("Hace 10 min", DateTimeFormatter.format(datetime.minus(10.minutes).toEpochMilliseconds(), config)) } } ================================================ FILE: commonbiz/common/src/commonTest/kotlin/com/zhangke/fread/common/utils/FormalUriTest.kt ================================================ package com.zhangke.fread.common.utils import com.zhangke.fread.status.uri.FormalUri const val ACTIVITY_PUB_HOST = "activitypub.com" private fun createActivityPubUri(path: String, queries: Map): FormalUri { return FormalUri.create( host = ACTIVITY_PUB_HOST, path = path, queries = queries, ) } fun createActivityPubUserUri( userId: String = "1", finger: String = "@AtomZ@m.cmx.im" ): FormalUri = createActivityPubUri( path = "/user", queries = mapOf( "userId" to userId, "finger" to finger, ), ) ================================================ FILE: commonbiz/common/src/commonTest/kotlin/com/zhangke/fread/common/utils/HashtagTextUtilsTest.kt ================================================ package com.zhangke.fread.common.utils import kotlin.test.Test import kotlin.test.assertContentEquals import androidx.compose.ui.text.TextRange class HashtagTextUtilsTest { @Test fun testMastodonHashtags() { // for Mastodon hashtag behavior see: // https://github.com/mastodon/mastodon/blob/e89acc2302df49cbd7815b031e9c2939632bd204/app/javascript/mastodon/utils/hashtags.ts assertContentEquals( listOf(), HashtagTextUtils.findHashtags("abc", false) ) assertContentEquals( listOf( TextRange(0, 4), ), HashtagTextUtils.findHashtags("#abc", false) ) assertContentEquals( listOf( TextRange(0, 4), ), HashtagTextUtils.findHashtags("#abc#def", false) ) assertContentEquals( listOf( TextRange(0, 4), TextRange(5, 9), ), HashtagTextUtils.findHashtags("#abc #def", false) ) assertContentEquals( listOf( TextRange(1, 5), ), HashtagTextUtils.findHashtags("##abc", false) ) assertContentEquals( listOf( TextRange(0, 2), TextRange(3, 7), ), HashtagTextUtils.findHashtags("#a##abc", false) ) assertContentEquals( listOf( TextRange(0, 2), TextRange(3, 7), TextRange(9, 11), ), HashtagTextUtils.findHashtags("#a!#b_c!##d", false) ) assertContentEquals( listOf(), HashtagTextUtils.findHashtags("foo #", false) ) assertContentEquals( listOf(), HashtagTextUtils.findHashtags("foo #_", false) ) assertContentEquals( listOf(), HashtagTextUtils.findHashtags("# a", false) ) assertContentEquals( listOf( TextRange(1, 3), ), HashtagTextUtils.findHashtags("(#a)", false) ) assertContentEquals( listOf(), HashtagTextUtils.findHashtags("()#a", false) ) assertContentEquals( listOf( TextRange(2, 4), ), HashtagTextUtils.findHashtags(")(#a", false) ) assertContentEquals( listOf(), HashtagTextUtils.findHashtags("/#a", false) ) /* // this will fail. the Mastodon client will process this as a hashtag, but this seems // like fairly obscure or even unintended behavior assertContentEquals( listOf( TextRange(0, 4), ), HashtagTextUtils.findHashtags("#___", false) )*/ } @Test fun testBlueskyHashtags() { // for Bluesky hashtag behavior see: // https://github.com/bluesky-social/atproto/blob/3cf5b31a2d8194dcfbfb8c3cc8e61282e48c9a82/packages/api/src/rich-text/util.ts#L10-L12 assertContentEquals( listOf(), HashtagTextUtils.findHashtags("abc", true), ) assertContentEquals( listOf( TextRange(0, 4), ), HashtagTextUtils.findHashtags("#abc", true), ) assertContentEquals( listOf( TextRange(0, 8), ), HashtagTextUtils.findHashtags("#abc#def", true) ) assertContentEquals( listOf( TextRange(0, 4), TextRange(5, 9), ), HashtagTextUtils.findHashtags("#abc #def", true) ) assertContentEquals( listOf( TextRange(0, 5), ), HashtagTextUtils.findHashtags("##abc", true) ) assertContentEquals( listOf( TextRange(0, 7), ), HashtagTextUtils.findHashtags("#a##abc", true) ) assertContentEquals( listOf( TextRange(0, 11), ), HashtagTextUtils.findHashtags("#a!#b_c!##d", true) ) assertContentEquals( listOf(), HashtagTextUtils.findHashtags("foo #", true) ) assertContentEquals( listOf(), HashtagTextUtils.findHashtags("foo #_", true) ) assertContentEquals( listOf(), HashtagTextUtils.findHashtags("# a", true) ) assertContentEquals( listOf(), HashtagTextUtils.findHashtags("(#a)", true) ) assertContentEquals( listOf(), HashtagTextUtils.findHashtags("()#a", true) ) assertContentEquals( listOf(), HashtagTextUtils.findHashtags(")(#a", true) ) assertContentEquals( listOf(), HashtagTextUtils.findHashtags("/#a", true) ) assertContentEquals( listOf( TextRange(0, 2), ), HashtagTextUtils.findHashtags("#a! b", true) ) assertContentEquals( listOf( TextRange(0, 2), ), HashtagTextUtils.findHashtags("#a!", true) ) assertContentEquals( listOf( TextRange(0, 4), ), HashtagTextUtils.findHashtags("#a!b", true) ) assertContentEquals( listOf( TextRange(0, 6), ), HashtagTextUtils.findHashtags("#a!b!c", true) ) assertContentEquals( listOf( TextRange(0, 6), ), HashtagTextUtils.findHashtags("#a!b!c!", true) ) assertContentEquals( listOf( TextRange(0, 5), ), HashtagTextUtils.findHashtags("#a!!b", true) ) assertContentEquals( listOf(), HashtagTextUtils.findHashtags("#!! ", true) ) assertContentEquals( listOf( TextRange(0, 4), ), HashtagTextUtils.findHashtags("#!!a", true) ) assertContentEquals( listOf(), HashtagTextUtils.findHashtags("#", true) ) assertContentEquals( listOf( TextRange(0, 4) ), HashtagTextUtils.findHashtags("#a=b", true) ) assertContentEquals( listOf( TextRange(0, 2) ), HashtagTextUtils.findHashtags("#=", true) ) assertContentEquals( listOf(), HashtagTextUtils.findHashtags("a#b", true) ) assertContentEquals( listOf(), HashtagTextUtils.findHashtags("a#b c", true) ) } } ================================================ FILE: commonbiz/common/src/iosMain/kotlin/com/zhangke/fread/common/CommonIosModule.kt ================================================ package com.zhangke.fread.common import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.russhwolf.settings.NSUserDefaultsSettings import com.russhwolf.settings.coroutines.FlowSettings import com.russhwolf.settings.coroutines.toFlowSettings import com.zhangke.fread.common.browser.IosSystemBrowserLauncher import com.zhangke.fread.common.browser.SystemBrowserLauncher import com.zhangke.fread.common.db.ContentConfigDatabases import com.zhangke.fread.common.db.FreadContentDatabase import com.zhangke.fread.common.db.MixedStatusDatabases import com.zhangke.fread.common.db.old.OldFreadContentDatabase import com.zhangke.fread.common.handler.TextHandler import com.zhangke.fread.common.language.ActivityLanguageHelper import com.zhangke.fread.common.utils.MediaFileHelper import com.zhangke.fread.common.utils.PlatformUriHelper import com.zhangke.fread.common.utils.StorageHelper import com.zhangke.fread.common.utils.ToastHelper import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf import org.koin.dsl.bind import platform.Foundation.NSDocumentDirectory import platform.Foundation.NSFileManager import platform.Foundation.NSUserDefaults import platform.Foundation.NSUserDomainMask actual fun Module.createPlatformModule() { single { val dbFilePath = getDBFilePath(ContentConfigDatabases.DB_NAME) Room.databaseBuilder( name = dbFilePath, ).setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() } single { val dbFilePath = getDBFilePath(FreadContentDatabase.DB_NAME) Room.databaseBuilder( name = dbFilePath, ).setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() } single { val dbFilePath = getDBFilePath(OldFreadContentDatabase.DB_NAME) Room.databaseBuilder( name = dbFilePath, ).setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() } single { val dbFilePath = getDBFilePath(MixedStatusDatabases.DB_NAME) Room.databaseBuilder( name = dbFilePath, ).setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() } singleOf(::MediaFileHelper) singleOf(::PlatformUriHelper) singleOf(::StorageHelper) singleOf(::ToastHelper) singleOf(::ActivityLanguageHelper) singleOf(::TextHandler) singleOf(::IosSystemBrowserLauncher) bind SystemBrowserLauncher::class single { NSUserDefaultsSettings(NSUserDefaults.standardUserDefaults).toFlowSettings() }.bind(FlowSettings::class) } private fun getDBFilePath(dbName: String): String { return documentDirectory() + "/$dbName" } @OptIn(ExperimentalForeignApi::class) fun documentDirectory(): String { val documentDirectory = NSFileManager.defaultManager.URLForDirectory( directory = NSDocumentDirectory, inDomain = NSUserDomainMask, appropriateForURL = null, create = false, error = null, ) return requireNotNull(documentDirectory?.path) } ================================================ FILE: commonbiz/common/src/iosMain/kotlin/com/zhangke/fread/common/browser/IosSystemBrowserLauncher.kt ================================================ package com.zhangke.fread.common.browser import com.eygraber.uri.toNSURL import com.zhangke.framework.utils.PlatformUri import platform.SafariServices.SFSafariViewController import platform.UIKit.UIApplication import platform.UIKit.UIViewController class IosSystemBrowserLauncher( private val viewController: Lazy, private val application: UIApplication, ) : SystemBrowserLauncher { override fun launchBySystemBrowser(uri: PlatformUri) { application.openURL(uri.toNSURL()!!) } override fun launchWebTabInApp(uri: PlatformUri) { try { val safari = SFSafariViewController(uri.toNSURL()!!) // safari.modalPresentationStyle = UIModalPresentationPageSheet viewController.value.presentViewController(safari, animated = true, completion = null) } catch (e: Exception) { e.printStackTrace() launchBySystemBrowser(uri) } } } ================================================ FILE: commonbiz/common/src/iosMain/kotlin/com/zhangke/fread/common/browser/OAuthLauncher.ios.kt ================================================ package com.zhangke.fread.common.browser import kotlinx.coroutines.suspendCancellableCoroutine import platform.AuthenticationServices.ASWebAuthenticationPresentationContextProvidingProtocol import platform.AuthenticationServices.ASWebAuthenticationSession import platform.Foundation.NSError import platform.Foundation.NSURL import platform.Foundation.NSURLComponents import platform.Foundation.NSURLQueryItem import platform.UIKit.UIApplication import platform.UIKit.UIWindow import platform.darwin.NSObject import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException actual class OAuthHandler ( private val application: UIApplication, ) { @Suppress("UNCHECKED_CAST") actual suspend fun startOAuth(url: String): String = suspendCancellableCoroutine { continuation -> val webAuthSession = ASWebAuthenticationSession( uRL = NSURL(string = url), callbackURLScheme = "freadapp" ) { callbackURL: NSURL?, error: NSError? -> when { error != null -> { when (error.code) { 1L -> { continuation.cancel() } else -> { continuation.resumeWithException(RuntimeException(error.localizedDescription)) } } } callbackURL != null -> { val components = NSURLComponents(uRL = callbackURL, resolvingAgainstBaseURL = false) val queryItems = components.queryItems as? List val code = queryItems?.firstOrNull { it.name == "code" }?.value if (code != null) { continuation.resume(code) } else { continuation.resumeWithException(RuntimeException("No code received")) } } else -> { continuation.cancel() } } } val contextProvider = object : NSObject(), ASWebAuthenticationPresentationContextProvidingProtocol { override fun presentationAnchorForWebAuthenticationSession( session: ASWebAuthenticationSession ): UIWindow { return application.keyWindow!! } override fun description(): String { return "ASWebAuthenticationPresentationContextProvider" } override fun hash(): ULong { return hashCode().toULong() } override fun isEqual(`object`: Any?): Boolean { return this === `object` } } webAuthSession.presentationContextProvider = contextProvider webAuthSession.prefersEphemeralWebBrowserSession = false webAuthSession.start() } } ================================================ FILE: commonbiz/common/src/iosMain/kotlin/com/zhangke/fread/common/daynight/DayNightPlatformHelper.ios.kt ================================================ package com.zhangke.fread.common.daynight actual class DayNightPlatformHelper { actual fun setDefaultMode(modeValue: DayNightMode) { } actual fun setMode(mode: DayNightMode) { } actual fun setAmoledMode(enabled: Boolean) { } } ================================================ FILE: commonbiz/common/src/iosMain/kotlin/com/zhangke/fread/common/handler/TextHandler.ios.kt ================================================ package com.zhangke.fread.common.handler import com.zhangke.fread.common.utils.SystemUtils import platform.Foundation.NSBundle import platform.Foundation.NSURL import platform.UIKit.UIActivityViewController import platform.UIKit.UIApplication import platform.UIKit.UIPasteboard actual class TextHandler () { actual val packageName: String get() = NSBundle.mainBundle().bundleIdentifier().orEmpty() actual val versionName: String get() = NSBundle.mainBundle().infoDictionary()?.get("CFBundleShortVersionString") as? String ?: "" actual val versionCode: String get() = NSBundle.mainBundle().infoDictionary()?.get("CFBundleVersion") as? String ?: "" actual fun copyText(text: String) { val pasteboard = UIPasteboard.generalPasteboard() pasteboard.string = text } actual fun shareUrl(url: String, text: String) { val items = listOf(url, text) val activityViewController = UIActivityViewController(items, null) val rootViewController = UIApplication.sharedApplication.keyWindow?.rootViewController rootViewController?.presentViewController( activityViewController, animated = true, completion = null ) } actual fun openSendEmail() { val mailtoUrl = "mailto:" val url = NSURL.URLWithString(mailtoUrl) if (url != null && UIApplication.sharedApplication.canOpenURL(url)) { UIApplication.sharedApplication.openURL(url) } } actual fun openAppMarket() { SystemUtils.openAppStore(packageName) } } ================================================ FILE: commonbiz/common/src/iosMain/kotlin/com/zhangke/fread/common/language/LanguageHelper.ios.kt ================================================ package com.zhangke.fread.common.language import com.zhangke.framework.architect.coroutines.ApplicationScope import com.zhangke.fread.common.config.LocalConfigManager import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking class LanguageHelper ( private val localConfigManager: LocalConfigManager, ) { var currentLanguage = readLocalFromStorage() private set fun initialize() { } private fun readLocalFromStorage(): LanguageSettingItem { return runBlocking { localConfigManager.getString(LOCAL_KEY_LANGUAGE) ?.let { LanguageSettingItem.fromLocalId(it) } ?: LanguageSettingItem.FollowSystem } } private fun saveLocalToStorage(item: LanguageSettingItem) { ApplicationScope.launch { localConfigManager.putString(LOCAL_KEY_LANGUAGE, item.localId) } } fun setLanguage(item: LanguageSettingItem) { currentLanguage = item saveLocalToStorage(item) } } actual class ActivityLanguageHelper( private val languageHelper: LanguageHelper, ) { actual val currentLanguage get() = languageHelper.currentLanguage actual fun initialize() { languageHelper.initialize() } actual fun setLanguage(item: LanguageSettingItem) { languageHelper.setLanguage(item) } } ================================================ FILE: commonbiz/common/src/iosMain/kotlin/com/zhangke/fread/common/page/BasePagerTabHookManager.ios.kt ================================================ package com.zhangke.fread.common.page internal actual fun findBasePagerTabImplementers(): List { // TODO: Not yet implemented return emptyList() } ================================================ FILE: commonbiz/common/src/iosMain/kotlin/com/zhangke/fread/common/update/AppPlatformUpdater.ios.kt ================================================ package com.zhangke.fread.common.update import com.zhangke.fread.common.utils.SystemUtils import platform.Foundation.NSBundle actual class AppPlatformUpdater { actual val platformName: String = "ios" actual val signingForFDroid: Boolean = false actual fun getAppVersionCode(): Long { val versionCode = NSBundle.mainBundle().infoDictionary()?.get("CFBundleVersion") as? String ?: "" return versionCode.toLongOrNull() ?: 0L } actual fun triggerUpdate(releaseInfo: AppReleaseInfo) { SystemUtils.openAppStore(NSBundle.mainBundle().bundleIdentifier().orEmpty()) } } ================================================ FILE: commonbiz/common/src/iosMain/kotlin/com/zhangke/fread/common/utils/MediaFileHelper.ios.kt ================================================ package com.zhangke.fread.common.utils actual class MediaFileHelper () { actual fun saveImageToGallery(url: String) { TODO("Not yet implemented") } actual fun saveVideoToGallery(url: String) { TODO("Not yet implemented") } } ================================================ FILE: commonbiz/common/src/iosMain/kotlin/com/zhangke/fread/common/utils/PlatformUriHelper.ios.kt ================================================ package com.zhangke.fread.common.utils import com.zhangke.framework.utils.ContentProviderFile import com.zhangke.framework.utils.PlatformUri actual class PlatformUriHelper () { actual suspend fun read(uri: PlatformUri): ContentProviderFile? { TODO("Not yet implemented") } actual suspend fun readBytes(uri: PlatformUri): ByteArray? { TODO("Not yet implemented") } actual fun queryFileName(uri: PlatformUri): String? { TODO("Not yet implemented") } } ================================================ FILE: commonbiz/common/src/iosMain/kotlin/com/zhangke/fread/common/utils/RandomIdGenerator.ios.kt ================================================ package com.zhangke.fread.common.utils import kotlinx.datetime.Clock actual class RandomIdGenerator { actual fun generateId(): String { // TODO get device info for generate id return Clock.System.now().toString() } } ================================================ FILE: commonbiz/common/src/iosMain/kotlin/com/zhangke/fread/common/utils/StorageHelper.ios.kt ================================================ package com.zhangke.fread.common.utils import okio.Path import okio.Path.Companion.toPath import platform.Foundation.NSCachesDirectory import platform.Foundation.NSSearchPathForDirectoriesInDomains import platform.Foundation.NSUserDomainMask actual class StorageHelper () { actual val cacheDir: Path get() = getCacheDir().toPath() } private fun getCacheDir(): String { return NSSearchPathForDirectoriesInDomains( NSCachesDirectory, NSUserDomainMask, true, ).first() as String } ================================================ FILE: commonbiz/common/src/iosMain/kotlin/com/zhangke/fread/common/utils/SystemUtils.kt ================================================ package com.zhangke.fread.common.utils import platform.Foundation.NSURL import platform.UIKit.UIApplication object SystemUtils { fun openAppStore(packageName: String) { val appStoreUrl = "https://apps.apple.com/cn/app/id${packageName}" val url = NSURL.URLWithString(appStoreUrl) if (url != null && UIApplication.sharedApplication.canOpenURL(url)) { UIApplication.sharedApplication.openURL(url) } } } ================================================ FILE: commonbiz/common/src/iosMain/kotlin/com/zhangke/fread/common/utils/ToastHelper.ios.kt ================================================ package com.zhangke.fread.common.utils actual class ToastHelper () { actual fun showToast(content: String) { TODO("Not yet implemented") } } ================================================ FILE: commonbiz/sharedscreen/.gitignore ================================================ /build ================================================ FILE: commonbiz/sharedscreen/build.gradle.kts ================================================ plugins { id("fread.project.framework.kmp") id("com.google.devtools.ksp") id("kotlin-parcelize") alias(libs.plugins.room) } android { namespace = "com.zhangke.fread.commonbiz.shared.screen" } kotlin { sourceSets { commonMain { dependencies { implementation(project(path = ":framework")) implementation(project(path = ":commonbiz:common")) implementation(project(path = ":bizframework:status-provider")) implementation(project(path = ":commonbiz:status-ui")) implementation(project(":commonbiz:analytics")) implementation(compose.components.resources) implementation(libs.androidx.room) implementation(libs.compose.jb.backhandler) implementation(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) implementation(libs.jetbrains.lifecycle.viewmodel) implementation(libs.imageLoader) implementation(libs.krouter.runtime) implementation(libs.androidx.paging.common) } } commonTest { dependencies { implementation(kotlin("test")) } } androidMain { dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.annotation) implementation(libs.bundles.androidx.fragment) implementation(libs.bundles.androidx.activity) implementation(libs.bundles.androidx.preference) implementation(libs.bundles.androidx.datastore) implementation(libs.bundles.androidx.collection) implementation(libs.androidx.browser) implementation(libs.auto.service.annotations) } } } } dependencies { kspAll(libs.androidx.room.compiler) add("kspAndroid", libs.auto.service.ksp) kspAll(libs.krouter.collecting.compiler) } compose { resources { publicResClass = true packageOfResClass = "com.zhangke.fread.commonbiz.shared.screen" generateResClass = always } } room { schemaDirectory("$projectDir/schemas") } ================================================ FILE: commonbiz/sharedscreen/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: commonbiz/sharedscreen/src/androidMain/kotlin/com/zhangke/fread/commonbiz/shared/SharedScreenAndroidEntryProvider.kt ================================================ package com.zhangke.fread.commonbiz.shared import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.zhangke.framework.nav.NavEntryProvider import kotlinx.serialization.modules.PolymorphicModuleBuilder class SharedScreenAndroidEntryProvider : NavEntryProvider { override fun EntryProviderScope.build() { } override fun PolymorphicModuleBuilder.polymorph() { } } ================================================ FILE: commonbiz/sharedscreen/src/androidMain/kotlin/com/zhangke/fread/commonbiz/shared/SharedScreenAndroidModule.kt ================================================ package com.zhangke.fread.commonbiz.shared import androidx.room.Room import com.zhangke.framework.nav.NavEntryProvider import com.zhangke.fread.commonbiz.shared.db.SelectedAccountPublishingDatabase import org.koin.android.ext.koin.androidContext import org.koin.core.module.Module import org.koin.core.module.dsl.factoryOf import org.koin.dsl.bind actual fun Module.createPlatformModule() { single { Room.databaseBuilder( androidContext(), SelectedAccountPublishingDatabase::class.java, SelectedAccountPublishingDatabase.DB_NAME, ).build() } factoryOf(::SharedScreenAndroidEntryProvider) bind NavEntryProvider::class } ================================================ FILE: commonbiz/sharedscreen/src/androidMain/kotlin/com/zhangke/fread/commonbiz/shared/composable/WebViewPreviewer.android.kt ================================================ package com.zhangke.fread.commonbiz.shared.composable import android.annotation.SuppressLint import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.zhangke.framework.utils.dpToPx import com.zhangke.framework.utils.toPlatformUri import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.common.browser.launchWebTabInApp @Composable actual fun WebViewPreviewer( html: String, modifier: Modifier, ) { val browserLauncher = LocalActivityBrowserLauncher.current val density = LocalDensity.current val fontColor = LocalContentColor.current.toArgb() val coroutineScope = rememberCoroutineScope() AndroidView( modifier = modifier, factory = { WebView(it).apply { this.setBackgroundColor(Color.Transparent.toArgb()) settings.defaultFontSize = 16.dp.dpToPx(density).toInt() @SuppressLint("SetJavaScriptEnabled") settings.javaScriptEnabled = true webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest, ): Boolean { browserLauncher.launchWebTabInApp(coroutineScope, request.url.toPlatformUri()) return true } } settings.loadWithOverviewMode = true settings.useWideViewPort = true } }, update = { val finalHtml = warpBlogContentHtml(html, fontColor) it.loadDataWithBaseURL("", finalHtml, "text/html", "UTF-8", null) }, ) } private fun warpBlogContentHtml( html: String, fontColor: Int, ): String { val colorString = String.format("#%06X", 0xFFFFFF and fontColor) return """ $html """.trimIndent() } ================================================ FILE: commonbiz/sharedscreen/src/androidMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/ImageViewerScreen.android.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen import com.seiko.imageloader.model.ImageResult import com.zhangke.framework.utils.aspectRatio internal actual fun ImageResult.aspectRatio(): Float? { return when (this) { is ImageResult.OfBitmap -> bitmap.width.toFloat() / bitmap.height is ImageResult.OfImage -> image.drawable.aspectRatio() else -> null } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/ModuleScreenVisitor.kt ================================================ package com.zhangke.fread.commonbiz.shared import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf import androidx.navigation3.runtime.NavKey class ModuleScreenVisitor( val feedsScreenVisitor: IFeedsScreenVisitor, val profileScreenVisitor: IProfileScreenVisitor, ) interface IFeedsScreenVisitor { fun getAddContentScreen(): NavKey } interface IProfileScreenVisitor { fun getDonateScreen(): NavKey } val LocalModuleScreenVisitor: ProvidableCompositionLocal = compositionLocalOf { error("ModuleScreenVisitor not init!") } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/SharedScreenModule.kt ================================================ package com.zhangke.fread.commonbiz.shared import com.zhangke.framework.nav.NavEntryProvider import com.zhangke.fread.commonbiz.shared.blog.detail.RssBlogDetailViewModel import com.zhangke.fread.commonbiz.shared.repo.SelectedAccountPublishingRepo import com.zhangke.fread.commonbiz.shared.screen.publish.multi.MultiAccountPublishingViewModel import com.zhangke.fread.commonbiz.shared.screen.status.account.SelectAccountOpenStatusViewModel import com.zhangke.fread.commonbiz.shared.screen.status.context.StatusContextViewModel import com.zhangke.fread.commonbiz.shared.usecase.PublishPostOnMultiAccountUseCase import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewBlogUseCase import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import org.koin.core.module.Module import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.bind import org.koin.dsl.module val sharedScreenModule = module { createPlatformModule() factoryOf(::SharedScreenNavEntryProvider) bind NavEntryProvider::class factoryOf(::SelectedAccountPublishingRepo) factoryOf(::RefactorToNewBlogUseCase) factoryOf(::RefactorToNewStatusUseCase) factoryOf(::PublishPostOnMultiAccountUseCase) viewModelOf(::StatusContextViewModel) viewModelOf(::RssBlogDetailViewModel) viewModelOf(::MultiAccountPublishingViewModel) viewModelOf(::SelectAccountOpenStatusViewModel) singleOf(::ModuleScreenVisitor) } expect fun Module.createPlatformModule() ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/SharedScreenNavEntryProvider.kt ================================================ package com.zhangke.fread.commonbiz.shared import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.zhangke.framework.architect.json.globalJson import com.zhangke.framework.nav.NavEntryProvider import com.zhangke.fread.commonbiz.shared.blog.detail.RssBlogDetailScreen import com.zhangke.fread.commonbiz.shared.blog.detail.RssBlogDetailScreenNavKey import com.zhangke.fread.commonbiz.shared.screen.ImageViewerScreen import com.zhangke.fread.commonbiz.shared.screen.ImageViewerScreenNavKey import com.zhangke.fread.commonbiz.shared.screen.SelectLanguageScreen import com.zhangke.fread.commonbiz.shared.screen.SelectLanguageScreenNavKey import com.zhangke.fread.commonbiz.shared.screen.publish.PublishBlogScreen import com.zhangke.fread.commonbiz.shared.screen.publish.PublishBlogScreenNavKey import com.zhangke.fread.commonbiz.shared.screen.publish.multi.MultiAccountPublishingScreen import com.zhangke.fread.commonbiz.shared.screen.publish.multi.MultiAccountPublishingScreenKey import com.zhangke.fread.commonbiz.shared.screen.status.context.StatusContextScreen import com.zhangke.fread.commonbiz.shared.screen.status.context.StatusContextScreenNavKey import com.zhangke.fread.commonbiz.shared.screen.video.FullVideoScreen import com.zhangke.fread.commonbiz.shared.screen.video.FullVideoScreenNavKey import kotlinx.serialization.modules.PolymorphicModuleBuilder import kotlinx.serialization.modules.subclass import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf class SharedScreenNavEntryProvider : NavEntryProvider { override fun EntryProviderScope.build() { entry { ImageViewerScreen( selectedIndex = it.selectedIndex, imageList = it.imageList, ) } entry { SelectLanguageScreen( selectedLanguages = it.selectedLanguages, maxSelectCount = it.maxSelectCount, ) } entry { PublishBlogScreen() } entry { val list = globalJson.decodeFromString>(it.userUrisJson) MultiAccountPublishingScreen( viewModel = koinViewModel { parametersOf(list) } ) } entry { StatusContextScreen( locator = it.locator, serializedStatus = it.serializedStatus, serializedBlog = it.serializedBlog, blogId = it.blogId, platform = it.platform, blogTranslationUiState = it.blogTranslationUiState, containerViewModel = koinViewModel(), ) } entry { RssBlogDetailScreen( serializedBlog = it.serializedBlog, viewModel = koinViewModel(), ) } entry { FullVideoScreen(it.uri) } } override fun PolymorphicModuleBuilder.polymorph() { subclass(ImageViewerScreenNavKey::class) subclass(SelectLanguageScreenNavKey::class) subclass(PublishBlogScreenNavKey::class) subclass(MultiAccountPublishingScreenKey::class) subclass(StatusContextScreenNavKey::class) subclass(RssBlogDetailScreenNavKey::class) subclass(FullVideoScreenNavKey::class) } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/blog/detail/RssBlogDetailScreen.kt ================================================ @file:OptIn(ExperimentalTime::class) package com.zhangke.fread.commonbiz.shared.blog.detail import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.architect.json.globalJson import com.zhangke.framework.composable.ConsumeOpenScreenFlow import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.ktx.ifNullOrEmpty import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.commonbiz.shared.composable.WebViewPreviewer import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.model.BlogTranslationUiState import com.zhangke.fread.status.ui.StatusInfoLine import com.zhangke.fread.status.ui.style.StatusStyles import com.zhangke.fread.status.utils.DateTimeFormatter import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource import kotlin.time.ExperimentalTime @Serializable data class RssBlogDetailScreenNavKey(val serializedBlog: String) : NavKey @Composable fun RssBlogDetailScreen( serializedBlog: String, viewModel: RssBlogDetailViewModel, ) { val blog: Blog = remember { globalJson.decodeFromString(serializedBlog) } val navigator = LocalNavBackStack.currentOrThrow val browserLauncher = LocalActivityBrowserLauncher.current val coroutineScope = rememberCoroutineScope() ConsumeOpenScreenFlow(viewModel.openScreenFlow) Scaffold( topBar = { Toolbar( title = blog.title.ifNullOrEmpty { stringResource(LocalizedString.sharedStatusContextScreenTitle) }, onBackClick = navigator::removeLastOrNull, actions = { SimpleIconButton( onClick = { coroutineScope.launch { browserLauncher.launchWebTabInApp( blog.url, checkAppSupportPage = false ) } }, imageVector = Icons.Default.OpenInBrowser, contentDescription = "Open In Browser", ) } ) } ) { innerPaddings -> Column( modifier = Modifier .padding(innerPaddings) .fillMaxSize() .verticalScroll(rememberScrollState()) .background(MaterialTheme.colorScheme.surface), ) { Spacer(modifier = Modifier.height(16.dp)) val displayTime by produceState("", blog.createAt) { value = DateTimeFormatter.format(blog.createAt.instant.toEpochMilliseconds()) } StatusInfoLine( modifier = Modifier.fillMaxWidth(), blog = blog, isOwner = false, visibility = blog.visibility, displayTime = displayTime, style = StatusStyles.medium(), onInteractive = { _, _ -> }, onUserInfoClick = viewModel::onUserInfoClick, onUrlClick = { coroutineScope.launch { browserLauncher.launchWebTabInApp(it) } }, blogTranslationState = BlogTranslationUiState(support = false), editedAt = blog.editedAt?.instant, showOpenBlogWithOtherAccountBtn = false, allowToShowFollowButton = false, onTranslateClick = {}, ) WebViewPreviewer( modifier = Modifier .padding(16.dp) .fillMaxSize(), html = blog.content, ) } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/blog/detail/RssBlogDetailViewModel.kt ================================================ package com.zhangke.fread.commonbiz.shared.blog.detail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation3.runtime.NavKey import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.author.BlogAuthor import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch class RssBlogDetailViewModel ( private val statusProvider: StatusProvider, ) : ViewModel() { private val _openScreenFlow = MutableSharedFlow() val openScreenFlow = _openScreenFlow.asSharedFlow() fun onUserInfoClick(author: BlogAuthor) { viewModelScope.launch { statusProvider.screenProvider .getUserDetailScreenWithoutAccount(author.uri) ?.let { _openScreenFlow.emit(it) } } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/composable/BlogUi.kt ================================================ package com.zhangke.fread.commonbiz.shared.composable import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.model.BlogTranslationUiState import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusVisibility import com.zhangke.fread.status.ui.BlogUi import com.zhangke.fread.status.ui.ComposedStatusInteraction import com.zhangke.fread.status.ui.getStatusTopLabel import com.zhangke.fread.status.ui.style.StatusStyle import com.zhangke.fread.status.ui.threads.ThreadsType import kotlinx.coroutines.launch @Composable fun BlogUi( modifier: Modifier, blog: Blog, locator: PlatformLocator, indexInList: Int, sharedElementId: String? = null, style: StatusStyle, showBottomPanel: Boolean, showMoreOperationIcon: Boolean, composedStatusInteraction: ComposedStatusInteraction, ) { val browserLauncher = LocalActivityBrowserLauncher.current val backState = LocalNavBackStack.currentOrThrow val fixedThreadType = if (blog.isReply) { ThreadsType.CONTINUED_THREAD } else { ThreadsType.NONE } var continueThreadHeight: Int? by remember { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() Box(modifier = modifier) { BlogUi( modifier = Modifier, blog = blog, logged = null, isOwner = null, blogTranslationState = BlogTranslationUiState.DEFAULT, indexInList = indexInList, sharedElementId = sharedElementId, showMoreOperationIcon = showMoreOperationIcon, style = style, threadsType = fixedThreadType, continueThreadLabelHeight = continueThreadHeight, topLabels = getStatusTopLabel( isReblog = false, pinned = blog.pinned, isReply = blog.isReply, author = blog.author, mentionOnly = blog.visibility == StatusVisibility.DIRECT, style = style, threadsType = fixedThreadType, onUserInfoClick = { composedStatusInteraction.onUserInfoClick(locator, it) }, onContinueThreadHeightChanged = { continueThreadHeight = it } ), onInteractive = { type, _ -> }, onUserInfoClick = { composedStatusInteraction.onUserInfoClick(locator, it) }, onMediaClick = { event -> onStatusMediaClick( navigator = backState, event = event, ) }, showDivider = false, showBottomPanel = showBottomPanel, onVoted = {}, onHashtagInStatusClick = { composedStatusInteraction.onHashtagInStatusClick(locator, it) }, onMentionClick = { composedStatusInteraction.onMentionClick(locator, it) }, onMentionDidClick = { composedStatusInteraction.onMentionClick( locator = locator, did = it, protocol = blog.platform.protocol, ) }, onUrlClick = { coroutineScope.launch { browserLauncher.launchWebTabInApp(it, locator) } }, onShowOriginalClick = {}, onTranslateClick = {}, onBlogClick = { composedStatusInteraction.onBlockClick(locator, it) }, onMaybeHashtagClick = { composedStatusInteraction.onMaybeHashtagClick( locator = locator, protocol = blog.platform.protocol, hashtag = it, ) }, ) } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/composable/FeedsContent.kt ================================================ package com.zhangke.fread.commonbiz.shared.composable import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeOpenScreenFlow import com.zhangke.framework.composable.LocalContentPadding import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.applyNestedScrollConnection import com.zhangke.framework.composable.textString import com.zhangke.framework.loadable.lazycolumn.LoadableInlineVideoLazyColumn import com.zhangke.framework.loadable.lazycolumn.rememberLoadableInlineVideoLazyColumnState import com.zhangke.framework.utils.LoadState import com.zhangke.fread.common.composable.EmptyContent import com.zhangke.fread.common.composable.EmptyContentType import com.zhangke.fread.common.composable.ErrorContent import com.zhangke.fread.common.composable.ErrorType import com.zhangke.fread.commonbiz.shared.feeds.CommonFeedsUiState import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.account.isAuthenticationFailure import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.ui.ComposedStatusInteraction import com.zhangke.fread.status.ui.StatusListPlaceholder import com.zhangke.fread.status.ui.common.LocalNestedTabConnection import com.zhangke.fread.status.ui.common.NewStatusNotifyBar import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import kotlin.time.Duration.Companion.seconds @Composable fun FeedsContent( uiState: CommonFeedsUiState, openScreenFlow: SharedFlow, newStatusNotifyFlow: SharedFlow?, onRefresh: () -> Unit, onLoadMore: () -> Unit, composedStatusInteraction: ComposedStatusInteraction, nestedScrollConnection: NestedScrollConnection? = null, observeScrollToTopEvent: Boolean = false, onScrollToTopConsumed: (() -> Unit)? = null, contentCanScrollBackward: MutableState? = null, onImmersiveEvent: ((immersive: Boolean) -> Unit)? = null, onScrollInProgress: ((Boolean) -> Unit)? = null, onLoginClick: (() -> Unit)? = null, ) { ConsumeOpenScreenFlow(openScreenFlow) FeedsContent( feeds = uiState.feeds, refreshing = uiState.refreshing, loadMoreState = uiState.loadMoreState, showPagingLoadingPlaceholder = uiState.showPagingLoadingPlaceholder, pageErrorContent = uiState.pageErrorContent, newStatusNotifyFlow = newStatusNotifyFlow, onRefresh = onRefresh, onLoadMore = onLoadMore, composedStatusInteraction = composedStatusInteraction, nestedScrollConnection = nestedScrollConnection, observeScrollToTopEvent = observeScrollToTopEvent, onScrollToTopConsumed = onScrollToTopConsumed, contentCanScrollBackward = contentCanScrollBackward, onImmersiveEvent = onImmersiveEvent, onScrollInProgress = onScrollInProgress, onLoginClick = onLoginClick, ) } @Composable fun FeedsContent( feeds: List, refreshing: Boolean, loadMoreState: LoadState, showPagingLoadingPlaceholder: Boolean, pageErrorContent: Throwable?, newStatusNotifyFlow: SharedFlow?, onRefresh: () -> Unit, onLoadMore: () -> Unit, composedStatusInteraction: ComposedStatusInteraction, nestedScrollConnection: NestedScrollConnection? = null, observeScrollToTopEvent: Boolean = false, onScrollToTopConsumed: (() -> Unit)? = null, contentCanScrollBackward: MutableState? = null, onImmersiveEvent: ((immersive: Boolean) -> Unit)? = null, onScrollInProgress: ((Boolean) -> Unit)? = null, onLoginClick: (() -> Unit)? = null, ) { if (feeds.isEmpty()) { if (showPagingLoadingPlaceholder) { StatusListPlaceholder() } else if (pageErrorContent != null) { InitErrorContent( error = pageErrorContent, onLoginClick = onLoginClick, onRetryClick = onRefresh, ) } else { EmptyListContent() } } else { Box(modifier = Modifier.fillMaxSize()) { val state = rememberLoadableInlineVideoLazyColumnState( onRefresh = onRefresh, onLoadMore = onLoadMore, ) val lazyListState = state.lazyListState if (contentCanScrollBackward != null) { val canScrollBackward by remember { derivedStateOf { lazyListState.firstVisibleItemIndex != 0 || lazyListState.firstVisibleItemScrollOffset != 0 } } contentCanScrollBackward.value = canScrollBackward } if (onImmersiveEvent != null) { ObserveForImmersive( listState = lazyListState, onImmersiveEvent = onImmersiveEvent, ) } if (onScrollInProgress != null) { LaunchedEffect(lazyListState.isScrollInProgress) { onScrollInProgress(lazyListState.isScrollInProgress) } } val feedsConnection = LocalNestedTabConnection.current if (observeScrollToTopEvent) { LaunchedEffect(feedsConnection, lazyListState) { feedsConnection.scrollToTopFlow.collect { if (lazyListState.layoutInfo.totalItemsCount > 0) { lazyListState.scrollToItem(0) } onScrollToTopConsumed?.invoke() } } } LoadableInlineVideoLazyColumn( modifier = Modifier .fillMaxSize() .applyNestedScrollConnection(nestedScrollConnection), state = state, refreshing = refreshing, loadState = loadMoreState, ) { itemsIndexed(feeds) { index, item -> FeedsStatusNode( modifier = Modifier.fillMaxWidth(), status = item, composedStatusInteraction = composedStatusInteraction, indexInList = index, ) } } var showNewStatusNotifyBar by remember { mutableStateOf(false) } if (newStatusNotifyFlow != null) { ConsumeFlow(newStatusNotifyFlow) { delay(1000) if (state.lazyListState.firstVisibleItemIndex > 0) { showNewStatusNotifyBar = true } delay(20.seconds) } } val coroutineScope = rememberCoroutineScope() AnimatedVisibility( modifier = Modifier .padding(LocalContentPadding.current) .padding(top = 32.dp) .align(Alignment.TopCenter), visible = showNewStatusNotifyBar, ) { NewStatusNotifyBar( modifier = Modifier, onClick = { showNewStatusNotifyBar = false coroutineScope.launch { if (feeds.isNotEmpty()) { state.lazyListState.animateScrollToItem(0) onScrollToTopConsumed?.invoke() } } }, ) } } } } @Composable fun InitErrorContent( errorMessage: TextString, onRetryClick: (() -> Unit)? = null, ) { InitErrorContent( errorMessage = textString(text = errorMessage), onRetryClick = onRetryClick, ) } @Composable fun InitErrorContent( errorMessage: String, onRetryClick: (() -> Unit)? = null, ) { ErrorContent( modifier = Modifier.padding(LocalContentPadding.current).fillMaxSize(), type = ErrorType.Network, errorMessage = errorMessage, onRetryClick = onRetryClick ?: {}, ) } @Composable fun InitErrorContent( error: Throwable, onLoginClick: (() -> Unit)? = null, onRetryClick: (() -> Unit)? = null, ) { if (onLoginClick != null && error.isAuthenticationFailure) { NotLoginPageError( modifier = Modifier.padding(LocalContentPadding.current), message = error.message, onLoginClick = onLoginClick, ) } else { InitErrorContent( errorMessage = error.message.orEmpty(), onRetryClick = onRetryClick, ) } } @Composable fun NotLoginPageError( modifier: Modifier, message: String?, onLoginClick: () -> Unit, ) { EmptyContent( modifier = modifier.fillMaxSize(), type = EmptyContentType.Account, contentTitle = stringResource(LocalizedString.profileAccountNotLogin), subtitle = message, onClick = onLoginClick, ) } @Composable fun EmptyListContent() { EmptyContent( modifier = Modifier.fillMaxSize().padding(LocalContentPadding.current), type = EmptyContentType.Content, contentTitle = stringResource(LocalizedString.listContentEmptyPlaceholder), subtitle = null, onClick = null, ) } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/composable/FeedsStatusNode.kt ================================================ package com.zhangke.fread.commonbiz.shared.composable import androidx.compose.foundation.clickable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.commonbiz.shared.screen.status.account.SelectAccountOpenStatusBottomSheet import com.zhangke.fread.commonbiz.shared.screen.status.account.rememberSelectAccountOpenStatusSheetState import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.ui.ComposedStatusInteraction import com.zhangke.fread.status.ui.StatusUi import com.zhangke.fread.status.ui.style.LocalStatusUiConfig import com.zhangke.fread.status.ui.style.StatusStyle @Composable fun FeedsStatusNode( modifier: Modifier = Modifier, status: StatusUiState, indexInList: Int, composedStatusInteraction: ComposedStatusInteraction, showDivider: Boolean = true, sharedElementId: String? = null, style: StatusStyle = LocalStatusUiConfig.current.contentStyle, ) { val backStack = LocalNavBackStack.currentOrThrow val selectAccountOpenStatusBottomSheetState = rememberSelectAccountOpenStatusSheetState() StatusUi( modifier = modifier.clickable { composedStatusInteraction.onStatusClick(status) }, status = status, indexInList = indexInList, sharedElementId = sharedElementId, style = style, showDivider = showDivider, composedStatusInteraction = composedStatusInteraction, onMediaClick = { event -> onStatusMediaClick( navigator = backStack, event = event, ) }, onOpenBlogWithOtherAccountClick = { selectAccountOpenStatusBottomSheetState.show(it) }, ) SelectAccountOpenStatusBottomSheet(selectAccountOpenStatusBottomSheetState) } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/composable/ObserveForFeedsConnection.kt ================================================ package com.zhangke.fread.commonbiz.shared.composable import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import com.zhangke.framework.composable.ScrollDirection import com.zhangke.framework.composable.rememberDirectionalLazyListState import com.zhangke.fread.status.ui.common.LocalNestedTabConnection import kotlinx.coroutines.launch @Composable fun ObserveForFeedsConnection( listState: LazyListState, onRefresh: () -> Unit, ) { val mainTabConnection = LocalNestedTabConnection.current val coroutineScope = rememberCoroutineScope() LaunchedEffect(listState, mainTabConnection.scrollToTopFlow) { mainTabConnection.scrollToTopFlow .collect { if (listState.layoutInfo.totalItemsCount > 0) { coroutineScope.launch { listState.animateScrollToItem(0) } } } } LaunchedEffect(listState.isScrollInProgress) { mainTabConnection.updateContentScrollInProgress(listState.isScrollInProgress) } ObserveForImmersive( listState = listState, onImmersiveEvent = { if (it) { mainTabConnection.openImmersiveMode(coroutineScope) } else { mainTabConnection.closeImmersiveMode(coroutineScope) } } ) LaunchedEffect(mainTabConnection.refreshFlow) { mainTabConnection.refreshFlow.collect { onRefresh() } } } @Composable fun ObserveForImmersive( listState: LazyListState, onImmersiveEvent: (immersive: Boolean) -> Unit, ) { val directional = rememberDirectionalLazyListState(listState).scrollDirection LaunchedEffect(directional) { if (directional == ScrollDirection.Down) { onImmersiveEvent(true) } else if (directional == ScrollDirection.Up) { onImmersiveEvent(false) } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/composable/OnBlogMediaClick.kt ================================================ package com.zhangke.fread.commonbiz.shared.composable import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import com.zhangke.fread.commonbiz.shared.screen.ImageViewerImage import com.zhangke.fread.commonbiz.shared.screen.ImageViewerScreenNavKey import com.zhangke.fread.commonbiz.shared.screen.video.FullVideoScreenNavKey import com.zhangke.fread.status.blog.BlogMediaType import com.zhangke.fread.status.blog.asImageMetaOrNull import com.zhangke.fread.status.ui.image.BlogMediaClickEvent import com.zhangke.fread.status.ui.image.ClickedBlogMedia fun onStatusMediaClick( navigator: NavBackStack, event: BlogMediaClickEvent, ) { when (event) { is BlogMediaClickEvent.BlogImageClickEvent -> { if (event.mediaList[event.index].media.type == BlogMediaType.GIFV) { navigator.add(FullVideoScreenNavKey(event.mediaList[event.index].media.url)) return } navigator.add( ImageViewerScreenNavKey( imageList = event.mediaList.toImages(), selectedIndex = event.index, ) ) } is BlogMediaClickEvent.BlogVideoClickEvent -> { navigator.add(FullVideoScreenNavKey(event.media.url)) } } } fun ClickedBlogMedia.toImage(): ImageViewerImage { val media = this.media return ImageViewerImage( url = media.url, previewUrl = media.previewUrl, description = media.description, blurhash = media.blurhash, aspect = media.meta?.asImageMetaOrNull()?.original?.aspect, sharedElementKey = this.sharedElementKey, ) } fun List.toImages(): List { return this.map { it.toImage() } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/composable/SearchResultUi.kt ================================================ package com.zhangke.fread.commonbiz.shared.composable import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.common.browser.launchWebTabInApp import com.zhangke.fread.common.status.model.SearchResultUiState import com.zhangke.fread.status.ui.BlogAuthorUi import com.zhangke.fread.status.ui.ComposedStatusInteraction import com.zhangke.fread.status.ui.hashtag.HashtagUi import com.zhangke.fread.status.ui.source.BlogPlatformUi @Composable fun SearchResultUi( searchResult: SearchResultUiState, modifier: Modifier = Modifier, indexInList: Int, composedStatusInteraction: ComposedStatusInteraction, ) { val browserLauncher = LocalActivityBrowserLauncher.current val coroutineScope = rememberCoroutineScope() when (searchResult) { is SearchResultUiState.Platform -> { BlogPlatformUi( modifier = modifier, platform = searchResult.platform, ) } is SearchResultUiState.SearchedStatus -> { FeedsStatusNode( modifier = modifier, status = searchResult.status, indexInList = indexInList, composedStatusInteraction = composedStatusInteraction, ) } is SearchResultUiState.SearchedHashtag -> { HashtagUi( modifier = modifier, tag = searchResult.hashtag, onClick = { composedStatusInteraction.onHashtagClick(searchResult.locator, it) }, ) } is SearchResultUiState.Author -> { BlogAuthorUi( modifier = modifier, author = searchResult.author, onClick = { composedStatusInteraction.onUserInfoClick(searchResult.locator, it) }, onUrlClick = { browserLauncher.launchWebTabInApp(coroutineScope, it, searchResult.locator) } ) } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/composable/UserInfoCard.kt ================================================ package com.zhangke.fread.commonbiz.shared.composable import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable 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.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember 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.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.seiko.imageloader.ui.AutoSizeImage import com.zhangke.framework.composable.VerticalIndentLayout import com.zhangke.framework.utils.HighlightTextBuildUtil import com.zhangke.framework.utils.formatToHumanReadable import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.richtext.RichText import com.zhangke.fread.status.ui.BlogAuthorAvatar import com.zhangke.fread.status.ui.common.RelationshipStateButton import com.zhangke.fread.status.ui.richtext.SelectableRichText import com.zhangke.fread.status.ui.user.UserHandleLine import com.zhangke.fread.statusui.Res import com.zhangke.fread.statusui.img_banner_background import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource private const val BANNER_ASPECT = 3F @Composable fun UserInfoCard( modifier: Modifier, user: BlogAuthor, onUserClick: (BlogAuthor) -> Unit, onFollowAccountClick: (BlogAuthor) -> Unit, onUnfollowAccountClick: (BlogAuthor) -> Unit, onUnblockClick: (BlogAuthor) -> Unit, onCancelFollowRequestClick: (BlogAuthor) -> Unit, ) { BasicProfileCard( modifier = modifier, banner = user.banner.orEmpty(), avatar = user.avatar.orEmpty(), nickName = user.humanizedName, prettyHandle = user.prettyHandle, bot = user.bot, showActiveState = false, description = user.humanizedDescription, followersCount = user.followersCount, followingCount = user.followingCount, statusesCount = user.statusesCount, actionButton = if (user.relationships != null) { { RelationshipStateButton( modifier = Modifier, relationship = user.relationships!!, onFollowClick = { onFollowAccountClick(user) }, onUnfollowClick = { onUnfollowAccountClick(user) }, onUnblockClick = { onUnblockClick(user) }, onCancelFollowRequestClick = { onCancelFollowRequestClick(user) }, ) } } else { null }, onProfileClick = { onUserClick(user) }, ) } @Composable fun UserInfoCard( modifier: Modifier, user: BlogAuthor, showActiveState: Boolean, onUserClick: (BlogAuthor) -> Unit, actionButton: (@Composable () -> Unit)?, bottomPanel: (@Composable () -> Unit)? = null, ) { BasicProfileCard( modifier = modifier, banner = user.banner.orEmpty(), avatar = user.avatar.orEmpty(), nickName = user.humanizedName, prettyHandle = user.prettyHandle, bot = user.bot, showActiveState = showActiveState, description = user.humanizedDescription, followersCount = user.followersCount, followingCount = user.followingCount, statusesCount = user.statusesCount, actionButton = actionButton, onProfileClick = { onUserClick(user) }, bottomPanel = bottomPanel, ) } @Composable fun BasicProfileCard( modifier: Modifier, banner: String, avatar: String, nickName: RichText, prettyHandle: String, bot: Boolean, description: RichText, followersCount: Long?, followingCount: Long?, statusesCount: Long?, actionButton: (@Composable () -> Unit)?, showActiveState: Boolean, onProfileClick: () -> Unit, bottomPanel: (@Composable () -> Unit)? = null, ) { Box(modifier = modifier.clickable { onProfileClick() }) { Card( modifier = Modifier.fillMaxWidth(), ) { val avatarContainerHeight = 74.dp val overLapHeight = 22.dp val avatarSize = 68.dp VerticalIndentLayout( modifier = Modifier.fillMaxWidth(), indentHeight = overLapHeight, headerContent = { Box( modifier = Modifier.fillMaxWidth().aspectRatio(BANNER_ASPECT), ) { Image( modifier = Modifier.fillMaxSize(), painter = painterResource(Res.drawable.img_banner_background), contentDescription = null, contentScale = ContentScale.Crop, ) AutoSizeImage( modifier = Modifier.fillMaxSize(), url = banner, contentScale = ContentScale.Crop, contentDescription = null, placeholderPainter = { painterResource(Res.drawable.img_banner_background) }, errorPainter = { painterResource(Res.drawable.img_banner_background) }, ) } }, indentContent = { Column( modifier = Modifier.fillMaxWidth() .padding(start = 8.dp, end = 8.dp, bottom = 8.dp), ) { Box( modifier = Modifier.fillMaxWidth() .height(avatarContainerHeight) .padding(start = 8.dp), ) { BlogAuthorAvatar( modifier = Modifier .clip(CircleShape) .border( width = 2.dp, color = MaterialTheme.colorScheme.surfaceContainerHighest, shape = CircleShape, ) .size(avatarSize), imageUrl = avatar, ) Column( modifier = Modifier.padding(start = avatarSize, top = overLapHeight) .padding(start = 8.dp, top = 1.dp) ) { Row( modifier = Modifier, verticalAlignment = Alignment.CenterVertically, ) { SelectableRichText( modifier = Modifier, richText = nickName, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 18.sp, onUrlClick = {}, ) if (showActiveState) { Box( modifier = Modifier.padding(start = 4.dp) .size(6.dp) .clip(CircleShape) .background(Color(0xFF22C55E)) ) } } UserHandleLine( modifier = Modifier.padding(top = 1.dp), handle = prettyHandle, bot = bot, followedBy = false, ) } } SelectableRichText( modifier = Modifier .padding( start = 16.dp, end = 16.dp, ) .fillMaxWidth(), richText = description, onUrlClick = {}, fontSize = 14.sp, onMaybeHashtagClick = {}, ) Row( modifier = Modifier.fillMaxWidth().padding(start = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { if (followingCount != null && followersCount != null) { UserFollowInfo( modifier = Modifier.weight(1F), followersCount = followersCount, followingCount = followingCount, statusesCount = statusesCount, ) } else { Spacer(modifier = Modifier.weight(1F)) } if (actionButton != null) { actionButton() } else { Box(modifier = Modifier.size(height = 28.dp, width = 8.dp)) } } if (bottomPanel != null) { bottomPanel() } } } ) } } } @Composable private fun UserFollowInfo( modifier: Modifier, followersCount: Long?, followingCount: Long?, statusesCount: Long?, ) { val followerText = stringResource(LocalizedString.statusUiUserDetailFollowerInfo) val followingText = stringResource(LocalizedString.statusUiUserDetailFollowingInfo) val statusText = stringResource(LocalizedString.statusUiUserDetailPosts) val infoText = remember(followingCount, followersCount, statusesCount) { buildString { if (followersCount != null) { append(HighlightTextBuildUtil.HIGHLIGHT_START_SYMBOL) append(followersCount.formatToHumanReadable()) append(HighlightTextBuildUtil.HIGHLIGHT_END_SYMBOL) append(' ') append(followerText) } if (followingCount != null) { if (followersCount != null) { append(" · ") } append(HighlightTextBuildUtil.HIGHLIGHT_START_SYMBOL) append(followingCount.formatToHumanReadable()) append(HighlightTextBuildUtil.HIGHLIGHT_END_SYMBOL) append(' ') append(followingText) } if (statusesCount != null) { if (followersCount != null || followingCount != null) { append(" · ") } append(HighlightTextBuildUtil.HIGHLIGHT_START_SYMBOL) append(statusesCount.formatToHumanReadable()) append(HighlightTextBuildUtil.HIGHLIGHT_END_SYMBOL) append(' ') append(statusText) } }.let { HighlightTextBuildUtil.buildHighlightText( text = it, fontWeight = FontWeight.Bold, ) } } Text( text = infoText, modifier = modifier, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/composable/WebViewPreviewer.kt ================================================ package com.zhangke.fread.commonbiz.shared.composable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @Composable expect fun WebViewPreviewer( html: String, modifier: Modifier = Modifier, ) ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/db/SelectedAccountPublishingDatabase.kt ================================================ package com.zhangke.fread.commonbiz.shared.db import androidx.room.ConstructedBy import androidx.room.Dao import androidx.room.Database import androidx.room.Entity import androidx.room.Insert import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.RoomDatabase import androidx.room.RoomDatabaseConstructor private const val TABLE_NAME = "selected_account_publishing" @Entity(tableName = TABLE_NAME) data class SelectedAccountPublishing( @PrimaryKey val accountUri: String ) @Dao interface SelectedAccountPublishingDao { @Query("SELECT * FROM $TABLE_NAME") suspend fun getAll(): List @Insert suspend fun insert(items: List) @Query("DELETE FROM $TABLE_NAME") suspend fun deleteTable() } @Database( entities = [SelectedAccountPublishing::class], version = 1, exportSchema = false, ) @ConstructedBy(SelectedAccountPublishingDatabaseConstructor::class) abstract class SelectedAccountPublishingDatabase : RoomDatabase() { abstract fun dao(): SelectedAccountPublishingDao companion object { const val DB_NAME = "SelectedAccountPublishing.db" } } // The Room compiler generates the `actual` implementations. @Suppress("NO_ACTUAL_FOR_EXPECT") expect object SelectedAccountPublishingDatabaseConstructor : RoomDatabaseConstructor { override fun initialize(): SelectedAccountPublishingDatabase } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/feeds/CommonFeedsUiState.kt ================================================ package com.zhangke.fread.commonbiz.shared.feeds import com.zhangke.framework.utils.LoadState import com.zhangke.fread.status.model.StatusUiState data class CommonFeedsUiState( val feeds: List, val showPagingLoadingPlaceholder: Boolean, val pageErrorContent: Throwable?, val refreshing: Boolean, val loadMoreState: LoadState, ) ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/feeds/FeedsViewModelController.kt ================================================ package com.zhangke.fread.commonbiz.shared.feeds import com.zhangke.framework.collections.container import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.composable.toTextStringOrNull import com.zhangke.framework.utils.LoadState import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.feeds.model.RefreshResult import com.zhangke.fread.common.status.StatusConfigurationDefault import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.blog.BlogPoll import com.zhangke.fread.status.model.Hashtag import com.zhangke.fread.status.model.HashtagInStatus import com.zhangke.fread.status.model.Mention import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusActionType import com.zhangke.fread.status.model.StatusProviderProtocol import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.model.updateStatus import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.richtext.preParse import com.zhangke.fread.status.status.model.Status import com.zhangke.fread.status.ui.ComposedStatusInteraction import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class FeedsViewModelController( statusProvider: StatusProvider, private val statusUiStateAdapter: StatusUiStateAdapter, statusUpdater: StatusUpdater, refactorToNewStatus: RefactorToNewStatusUseCase, ) : IFeedsViewModelController { private lateinit var coroutineScope: CoroutineScope private lateinit var locatorResolver: (Status) -> PlatformLocator private lateinit var loadFirstPageLocalFeeds: suspend () -> Result> private lateinit var loadNewFromServerFunction: suspend () -> Result private lateinit var loadMoreFunction: suspend (maxId: String) -> Result> private lateinit var onStatusUpdate: suspend (Status) -> Unit private val interactiveHandler = InteractiveHandler( statusProvider = statusProvider, statusUpdater = statusUpdater, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, ) override val mutableUiState = MutableStateFlow( CommonFeedsUiState( feeds = emptyList(), showPagingLoadingPlaceholder = false, pageErrorContent = null, refreshing = false, loadMoreState = LoadState.Idle, ) ) override val mutableNewStatusNotifyFlow = MutableSharedFlow() override val mutableErrorMessageFlow = interactiveHandler.mutableErrorMessageFlow override val errorMessageFlow = interactiveHandler.errorMessageFlow override val mutableOpenScreenFlow = interactiveHandler.mutableOpenScreenFlow override val composedStatusInteraction: ComposedStatusInteraction get() = interactiveHandler.composedStatusInteraction private var initFeedsJob: Job? = null private var refreshJob: Job? = null private var loadMoreJob: Job? = null private var autoFetchNewerFeedsJob: Job? = null override fun initController( coroutineScope: CoroutineScope, locatorResolver: (Status) -> PlatformLocator, loadFirstPageLocalFeeds: suspend () -> Result>, loadNewFromServerFunction: suspend () -> Result, loadMoreFunction: suspend (maxId: String) -> Result>, onStatusUpdate: suspend (Status) -> Unit ) { this.coroutineScope = coroutineScope this.locatorResolver = locatorResolver this.loadFirstPageLocalFeeds = loadFirstPageLocalFeeds this.loadNewFromServerFunction = loadNewFromServerFunction this.loadMoreFunction = loadMoreFunction this.onStatusUpdate = onStatusUpdate interactiveHandler.initInteractiveHandler( coroutineScope = coroutineScope, onInteractiveHandleResult = { it.handleResult() }, ) } override fun initFeeds(needLocalData: Boolean) { initFeedsJob?.cancel() initFeedsJob = coroutineScope.launch { mutableUiState.update { it.copy( showPagingLoadingPlaceholder = true, pageErrorContent = null, feeds = emptyList(), ) } if (needLocalData) { loadFirstPageLocalFeeds() .map { list -> list.preParse() list } .onSuccess { localStatus -> if (localStatus.isNotEmpty()) { mutableUiState.update { state -> state.copy( feeds = localStatus, showPagingLoadingPlaceholder = false, ) } } } } loadNewFromServerFunction() .map { result -> val newStatus = result.newStatus newStatus.preParse() newStatus } .onFailure { mutableUiState.update { state -> state.copy( showPagingLoadingPlaceholder = false, pageErrorContent = if (state.feeds.isEmpty()) { it } else { null }, ) } if (mutableUiState.value.feeds.isNotEmpty()) { mutableErrorMessageFlow.emitTextMessageFromThrowable(it) } }.onSuccess { mutableUiState.update { state -> state.copy( feeds = it, showPagingLoadingPlaceholder = false, ) } } } } override fun startAutoFetchNewerFeeds() { if (autoFetchNewerFeedsJob != null) return autoFetchNewerFeedsJob = coroutineScope.launch { while (true) { delay(StatusConfigurationDefault.config.autoFetchNewerFeedsInterval) autoFetchNewerFeeds() } } } private suspend fun autoFetchNewerFeeds() { loadNewFromServerFunction() .map { it.newStatus.preParse() it } .onSuccess { val oldFirstId = mutableUiState.value.feeds.firstOrNull()?.status?.id val newFirstId = it.newStatus.firstOrNull()?.status?.id mutableUiState.update { state -> state.copy( feeds = state.feeds.applyRefreshResult(it), ) } if (it.newStatus.isNotEmpty() && oldFirstId != newFirstId) { mutableNewStatusNotifyFlow.emit(Unit) } } } override fun initInteractiveHandler( coroutineScope: CoroutineScope, onInteractiveHandleResult: suspend (InteractiveHandleResult) -> Unit ) { interactiveHandler.initInteractiveHandler( coroutineScope = coroutineScope, onInteractiveHandleResult = onInteractiveHandleResult, ) } override fun onStatusInteractive( status: StatusUiState, type: StatusActionType ) { interactiveHandler.onStatusInteractive(status, type) } override fun onUserInfoClick(locator: PlatformLocator, blogAuthor: BlogAuthor) { interactiveHandler.onUserInfoClick(locator, blogAuthor) } override fun onStatusClick(status: StatusUiState) { interactiveHandler.onStatusClick(status) } override fun onBlogClick(locator: PlatformLocator, blog: Blog) { interactiveHandler.onBlogClick(locator, blog) } override fun onBlogIdClick(locator: PlatformLocator, platform: BlogPlatform, blogId: String) { interactiveHandler.onBlogIdClick(locator, platform, blogId) } override fun onVoted(status: StatusUiState, votedOption: List) { interactiveHandler.onVoted(status, votedOption) } override fun onFollowClick(locator: PlatformLocator, target: BlogAuthor) { interactiveHandler.onFollowClick(locator, target) } override fun onUnfollowClick(locator: PlatformLocator, target: BlogAuthor) { interactiveHandler.onUnfollowClick(locator, target) } override fun onMentionClick(locator: PlatformLocator, mention: Mention) { interactiveHandler.onMentionClick(locator, mention) } override fun onMentionClick( locator: PlatformLocator, did: String, protocol: StatusProviderProtocol ) { interactiveHandler.onMentionClick(locator, did, protocol) } override fun onHashtagClick(locator: PlatformLocator, tag: HashtagInStatus) { interactiveHandler.onHashtagClick(locator, tag) } override fun onHashtagClick(locator: PlatformLocator, tag: Hashtag) { interactiveHandler.onHashtagClick(locator, tag) } override fun onMaybeHashtagClick( locator: PlatformLocator, protocol: StatusProviderProtocol, tag: String, ) { interactiveHandler.onMaybeHashtagClick(locator, protocol, tag) } override fun onRefresh() { val uiState = mutableUiState.value if (uiState.showPagingLoadingPlaceholder || uiState.refreshing || uiState.loadMoreState.loading) return refreshJob?.cancel() refreshJob = coroutineScope.launch { mutableUiState.update { it.copy(refreshing = true) } loadNewFromServerFunction() .onSuccess { refreshResult -> mutableUiState.update { it.copy( refreshing = false, feeds = it.feeds.applyRefreshResult(refreshResult), ) } }.onFailure { e -> mutableErrorMessageFlow.emitTextMessageFromThrowable(e) mutableUiState.update { it.copy(refreshing = false) } } } } override fun onLoadMore() { val uiState = mutableUiState.value if (uiState.showPagingLoadingPlaceholder || uiState.refreshing || uiState.loadMoreState.loading) return val feeds = uiState.feeds if (feeds.isEmpty()) return loadMoreJob?.cancel() loadMoreJob = coroutineScope.launch { mutableUiState.update { it.copy(loadMoreState = LoadState.Loading) } loadMoreFunction(feeds.last().status.id) .onFailure { e -> mutableUiState.update { it.copy( loadMoreState = LoadState.Failed(e.toTextStringOrNull()), ) } }.onSuccess { list -> mutableUiState.update { val newList = it.feeds.toMutableList() newList.addAllIgnoreDuplicate(list) it.copy( loadMoreState = LoadState.Idle, feeds = newList, ) } } } } private fun List.applyRefreshResult( refreshResult: RefreshResult, ): List { if (refreshResult.useOldData) { val deletedIdsSet = refreshResult.deletedStatus .map { it.status.id } .toSet() val oldList = this.filter { !deletedIdsSet.contains(it.status.id) } val addedNewList = refreshResult.newStatus .toMutableList() addedNewList.addAllIgnoreDuplicate(oldList) return addedNewList } else { return refreshResult.newStatus } } private fun MutableList.addAllIgnoreDuplicate( newItems: List, ) { newItems.forEach { this.addIfNotExist(it) } } private fun MutableList.addIfNotExist(newItemUiState: StatusUiState) { if (this.container { it.status.id == newItemUiState.status.id }) return this += newItemUiState } private suspend fun InteractiveHandleResult.handleResult() { this.handle( uiStatusUpdater = { newUiState -> onStatusUpdate(newUiState.status) mutableUiState.update { currentUiState -> currentUiState.copy(feeds = currentUiState.feeds.updateStatus(newUiState)) } }, deleteStatus = { deletedStatusId -> mutableUiState.update { currentUiState -> currentUiState.copy( feeds = currentUiState.feeds.filter { it.status.id != deletedStatusId } ) } }, followStateUpdater = { _, _ -> } ) } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/feeds/IFeedsViewModelController.kt ================================================ package com.zhangke.fread.commonbiz.shared.feeds import com.zhangke.fread.common.feeds.model.RefreshResult import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.status.model.Status import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow interface IFeedsViewModelController : IInteractiveHandler { val mutableUiState: MutableStateFlow val uiState: StateFlow get() = mutableUiState val mutableNewStatusNotifyFlow: MutableSharedFlow val newStatusNotifyFlow: SharedFlow get() = mutableNewStatusNotifyFlow fun initController( coroutineScope: CoroutineScope, locatorResolver: (Status) -> PlatformLocator, loadFirstPageLocalFeeds: suspend () -> Result>, loadNewFromServerFunction: suspend () -> Result, loadMoreFunction: suspend (maxId: String) -> Result>, onStatusUpdate: suspend (Status) -> Unit, ) fun initFeeds(needLocalData: Boolean) fun startAutoFetchNewerFeeds() fun onRefresh() fun onLoadMore() } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/feeds/IInteractiveHandler.kt ================================================ package com.zhangke.fread.commonbiz.shared.feeds import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.TextString import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.blog.BlogPoll import com.zhangke.fread.status.model.Hashtag import com.zhangke.fread.status.model.HashtagInStatus import com.zhangke.fread.status.model.Mention import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusActionType import com.zhangke.fread.status.model.StatusProviderProtocol import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.ui.ComposedStatusInteraction import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow interface IInteractiveHandler { val mutableErrorMessageFlow: MutableSharedFlow val errorMessageFlow: SharedFlow get() = mutableErrorMessageFlow val mutableOpenScreenFlow: MutableSharedFlow val openScreenFlow: SharedFlow get() = mutableOpenScreenFlow val composedStatusInteraction: ComposedStatusInteraction fun initInteractiveHandler( coroutineScope: CoroutineScope, onInteractiveHandleResult: suspend (InteractiveHandleResult) -> Unit, ) fun onStatusInteractive(status: StatusUiState, type: StatusActionType) fun onUserInfoClick(locator: PlatformLocator, blogAuthor: BlogAuthor) fun onStatusClick(status: StatusUiState) fun onBlogClick(locator: PlatformLocator, blog: Blog) fun onBlogIdClick(locator: PlatformLocator, platform: BlogPlatform, blogId: String) fun onVoted(status: StatusUiState, votedOption: List) fun onFollowClick(locator: PlatformLocator, target: BlogAuthor) fun onUnfollowClick(locator: PlatformLocator, target: BlogAuthor) fun onMentionClick(locator: PlatformLocator, mention: Mention) fun onMentionClick(locator: PlatformLocator, did: String, protocol: StatusProviderProtocol) fun onHashtagClick(locator: PlatformLocator, tag: HashtagInStatus) fun onHashtagClick(locator: PlatformLocator, tag: Hashtag) fun onMaybeHashtagClick(locator: PlatformLocator, protocol: StatusProviderProtocol, tag: String) } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/feeds/InteractiveHandleResult.kt ================================================ package com.zhangke.fread.commonbiz.shared.feeds import com.zhangke.framework.controller.CommonLoadableUiState import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.model.updateStatus import com.zhangke.fread.status.uri.FormalUri import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update sealed interface InteractiveHandleResult { data class UpdateStatus(val status: StatusUiState) : InteractiveHandleResult data class DeleteStatus(val statusId: String) : InteractiveHandleResult data class UpdateFollowState( val userUri: FormalUri, val following: Boolean, ) : InteractiveHandleResult } suspend fun InteractiveHandleResult.handle( uiStatusUpdater: suspend (StatusUiState) -> Unit, deleteStatus: (statusId: String) -> Unit, followStateUpdater: suspend (FormalUri, Boolean) -> Unit, ) { when (this) { is InteractiveHandleResult.UpdateStatus -> { uiStatusUpdater(this.status) } is InteractiveHandleResult.UpdateFollowState -> { val (userUri, following) = this followStateUpdater(userUri, following) } is InteractiveHandleResult.DeleteStatus -> { deleteStatus(this.statusId) } } } suspend fun InteractiveHandleResult.handle( mutableUiState: MutableStateFlow>, followStateUpdater: suspend (FormalUri, Boolean) -> Unit, ) { handle( uiStatusUpdater = { newStatus -> mutableUiState.update { it.copyObject( dataList = it.dataList.updateStatus(newStatus) ) } }, deleteStatus = { deletedStatusId -> mutableUiState.update { uiState -> uiState.copyObject( dataList = uiState.dataList .filter { status -> status.status.id != deletedStatusId } ) } }, followStateUpdater = followStateUpdater, ) } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/feeds/InteractiveHandler.kt ================================================ package com.zhangke.fread.commonbiz.shared.feeds import androidx.navigation3.runtime.NavKey import com.zhangke.framework.architect.json.globalJson import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.utils.exceptionOrThrow import com.zhangke.framework.utils.getDefaultLocale import com.zhangke.framework.utils.languageCode import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.blog.detail.RssBlogDetailScreenNavKey import com.zhangke.fread.commonbiz.shared.screen.status.context.StatusContextScreenNavKey import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.blog.BlogPoll import com.zhangke.fread.status.blog.BlogTranslation import com.zhangke.fread.status.model.Hashtag import com.zhangke.fread.status.model.HashtagInStatus import com.zhangke.fread.status.model.Mention import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusActionType import com.zhangke.fread.status.model.StatusProviderProtocol import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.model.isRss import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.ui.ComposedStatusInteraction import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import kotlinx.serialization.serializer class InteractiveHandler( private val statusProvider: StatusProvider, private val statusUpdater: StatusUpdater, private val statusUiStateAdapter: StatusUiStateAdapter, private val refactorToNewStatus: RefactorToNewStatusUseCase, ) : IInteractiveHandler { override val mutableErrorMessageFlow = MutableSharedFlow() override val mutableOpenScreenFlow = MutableSharedFlow() private lateinit var onInteractiveHandleResult: suspend (InteractiveHandleResult) -> Unit private lateinit var coroutineScope: CoroutineScope private val screenProvider = statusProvider.screenProvider override val composedStatusInteraction = object : ComposedStatusInteraction { override fun onStatusInteractive(status: StatusUiState, type: StatusActionType) { this@InteractiveHandler.onStatusInteractive(status, type) } override fun onUserInfoClick(locator: PlatformLocator, blogAuthor: BlogAuthor) { this@InteractiveHandler.onUserInfoClick(locator, blogAuthor) } override fun onVoted(status: StatusUiState, blogPollOptions: List) { this@InteractiveHandler.onVoted(status, blogPollOptions) } override fun onHashtagInStatusClick( locator: PlatformLocator, hashtagInStatus: HashtagInStatus ) { this@InteractiveHandler.onHashtagClick(locator, hashtagInStatus) } override fun onMaybeHashtagClick( locator: PlatformLocator, protocol: StatusProviderProtocol, hashtag: String, ) { this@InteractiveHandler.onMaybeHashtagClick(locator, protocol, hashtag) } override fun onMentionClick(locator: PlatformLocator, mention: Mention) { this@InteractiveHandler.onMentionClick(locator, mention) } override fun onMentionClick( locator: PlatformLocator, did: String, protocol: StatusProviderProtocol ) { this@InteractiveHandler.onMentionClick(locator, did, protocol) } override fun onStatusClick(status: StatusUiState) { this@InteractiveHandler.onStatusClick(status) } override fun onBlogClick(locator: PlatformLocator, blog: Blog) { this@InteractiveHandler.onBlogClick(locator, blog) } override fun onBlogIdClick( locator: PlatformLocator, platform: BlogPlatform, blogId: String ) { this@InteractiveHandler.onBlogIdClick(locator, platform, blogId) } override fun onBlockClick(locator: PlatformLocator, blog: Blog) { this@InteractiveHandler.onBlogClick(locator, blog) } override fun onFollowClick(locator: PlatformLocator, target: BlogAuthor) { this@InteractiveHandler.onFollowClick(locator, target) } override fun onUnfollowClick(locator: PlatformLocator, target: BlogAuthor) { this@InteractiveHandler.onUnfollowClick(locator, target) } override fun onHashtagClick(locator: PlatformLocator, tag: Hashtag) { this@InteractiveHandler.onHashtagClick(locator, tag) } override fun onBoostedClick(locator: PlatformLocator, status: StatusUiState) { this@InteractiveHandler.onBoostedClick(locator, status) } override fun onFavouritedClick(locator: PlatformLocator, status: StatusUiState) { this@InteractiveHandler.onFavouritedClick(locator, status) } override fun onTranslateClick(locator: PlatformLocator, status: StatusUiState) { this@InteractiveHandler.onTranslateClick(locator, status) } override fun onShowOriginalClick(status: StatusUiState) { this@InteractiveHandler.onShowOriginalClick(status) } } override fun initInteractiveHandler( coroutineScope: CoroutineScope, onInteractiveHandleResult: suspend (InteractiveHandleResult) -> Unit, ) { this.coroutineScope = coroutineScope this.onInteractiveHandleResult = onInteractiveHandleResult coroutineScope.launch { statusUpdater.statusUpdateFlow.collect { onInteractiveHandleResult(InteractiveHandleResult.UpdateStatus(it)) } } } override fun onStatusInteractive(status: StatusUiState, type: StatusActionType) { if (type == StatusActionType.REPLY) { screenProvider.getReplyBlogScreen(status.locator, status.status.intrinsicBlog) ?.let(::openScreen) return } if (type == StatusActionType.EDIT) { screenProvider.getEditBlogScreen(status.locator, status.status.intrinsicBlog) ?.let(::openScreen) return } if (type == StatusActionType.QUOTE) { screenProvider.getQuoteBlogScreen(status.locator, status.status.intrinsicBlog) ?.let(::openScreen) return } coroutineScope.launch { val result = statusProvider.statusResolver .interactive(status.locator, status.status, type) .map { s -> s?.let { statusUiStateAdapter.toStatusUiState(status, it) } } if (result.isFailure) { mutableErrorMessageFlow.emitTextMessageFromThrowable(result.exceptionOrThrow()) return@launch } val statusUiState = result.getOrNull() if (statusUiState == null) { onInteractiveHandleResult(InteractiveHandleResult.DeleteStatus(status.status.id)) } else { val interactiveResult = InteractiveHandleResult.UpdateStatus(statusUiState) statusUpdater.update(statusUiState) onInteractiveHandleResult(interactiveResult) } } } override fun onUserInfoClick(locator: PlatformLocator, blogAuthor: BlogAuthor) { coroutineScope.launch { screenProvider.getUserDetailScreen(locator, blogAuthor.uri, blogAuthor.userId) ?.let { mutableOpenScreenFlow.emit(it) } } } override fun onStatusClick(status: StatusUiState) { coroutineScope.launch { val screen = if (status.status.intrinsicBlog.platform.protocol.isRss) { RssBlogDetailScreenNavKey(serializedBlog = globalJson.encodeToString(status.status.intrinsicBlog)) } else { StatusContextScreenNavKey.create(refactorToNewStatus(status)) } mutableOpenScreenFlow.emit(screen) } } override fun onBlogClick(locator: PlatformLocator, blog: Blog) { coroutineScope.launch { val screen = if (blog.platform.protocol.isRss) { RssBlogDetailScreenNavKey( serializedBlog = globalJson.encodeToString( serializer(), blog ), ) } else { StatusContextScreenNavKey.create( locator = locator, blog = blog, ) } mutableOpenScreenFlow.emit(screen) } } override fun onBlogIdClick( locator: PlatformLocator, platform: BlogPlatform, blogId: String, ) { coroutineScope.launch { StatusContextScreenNavKey.create( locator = locator, blogId = blogId, platform = platform, ) } } override fun onVoted(status: StatusUiState, votedOption: List) { coroutineScope.launch { val result = statusProvider.statusResolver .votePoll(status.locator, status.status.intrinsicBlog, votedOption) .map { statusUiStateAdapter.toStatusUiState(status, it) } if (result.isFailure) { mutableErrorMessageFlow.emitTextMessageFromThrowable(result.exceptionOrThrow()) return@launch } val interactiveHandleResult = InteractiveHandleResult.UpdateStatus(result.getOrThrow()) onInteractiveHandleResult(interactiveHandleResult) } } override fun onFollowClick(locator: PlatformLocator, target: BlogAuthor) { coroutineScope.launch { statusProvider.statusResolver .follow(locator, target) .onSuccess { onInteractiveHandleResult( InteractiveHandleResult.UpdateFollowState( target.uri, true ) ) }.onFailure { mutableErrorMessageFlow.emitTextMessageFromThrowable(it) } } } override fun onUnfollowClick(locator: PlatformLocator, target: BlogAuthor) { coroutineScope.launch { statusProvider.statusResolver .unfollow(locator, target) .onSuccess { onInteractiveHandleResult( InteractiveHandleResult.UpdateFollowState( target.uri, false ) ) }.onFailure { mutableErrorMessageFlow.emitTextMessageFromThrowable(it) } } } override fun onMentionClick(locator: PlatformLocator, mention: Mention) { coroutineScope.launch { screenProvider.getUserDetailScreen( locator = locator, webFinger = mention.webFinger, protocol = mention.protocol, )?.let { mutableOpenScreenFlow.emit(it) } } } override fun onMentionClick( locator: PlatformLocator, did: String, protocol: StatusProviderProtocol ) { coroutineScope.launch { screenProvider.getUserDetailScreen( locator = locator, did = did, protocol = protocol, )?.let { mutableOpenScreenFlow.emit(it) } } } override fun onHashtagClick(locator: PlatformLocator, tag: HashtagInStatus) { openHashtagTimelineScreen( locator = locator, tag = tag.name, protocol = tag.protocol, ) } override fun onHashtagClick(locator: PlatformLocator, tag: Hashtag) { openHashtagTimelineScreen( locator = locator, tag = tag.name, protocol = tag.protocol, ) } override fun onMaybeHashtagClick( locator: PlatformLocator, protocol: StatusProviderProtocol, tag: String, ) { openHashtagTimelineScreen(locator = locator, tag = tag, protocol = protocol) } private fun openHashtagTimelineScreen( locator: PlatformLocator, tag: String, protocol: StatusProviderProtocol, ) { screenProvider.getTagTimelineScreen( locator = locator, tag = tag, protocol = protocol )?.let(::openScreen) } private fun onBoostedClick(locator: PlatformLocator, status: StatusUiState) { screenProvider.getBlogBoostedScreen( locator = locator, blog = status.status.intrinsicBlog, protocol = status.status.intrinsicBlog.platform.protocol, )?.let(::openScreen) } private fun onFavouritedClick(locator: PlatformLocator, status: StatusUiState) { screenProvider.getBlogFavouritedScreen( locator = locator, blog = status.status.intrinsicBlog, protocol = status.status.intrinsicBlog.platform.protocol, )?.let(::openScreen) } private fun onTranslateClick(locator: PlatformLocator, status: StatusUiState) { coroutineScope.launch { onInteractiveHandleResult(InteractiveHandleResult.UpdateStatus(status.translating())) statusProvider.statusResolver .translate(locator, status.status, getDefaultLocale().languageCode) .onFailure { mutableErrorMessageFlow.emitTextMessageFromThrowable(it) onInteractiveHandleResult(InteractiveHandleResult.UpdateStatus(status.translateFinish())) }.onSuccess { onInteractiveHandleResult( InteractiveHandleResult.UpdateStatus(status.translated(it)) ) } } } private fun onShowOriginalClick(status: StatusUiState) { coroutineScope.launch { val showOriginalBlog = status.copy( blogTranslationState = status.blogTranslationState.copy( showingTranslation = false, ) ) onInteractiveHandleResult(InteractiveHandleResult.UpdateStatus(showOriginalBlog)) } } private fun StatusUiState.translating(): StatusUiState { return this.copy( blogTranslationState = this.blogTranslationState.copy( translating = true, ) ) } private fun StatusUiState.translateFinish(): StatusUiState { return this.copy( blogTranslationState = this.blogTranslationState.copy( translating = false, ) ) } private fun StatusUiState.translated(translation: BlogTranslation): StatusUiState { return this.copy( blogTranslationState = this.blogTranslationState.copy( translating = false, blogTranslation = translation, showingTranslation = true, ) ) } private fun openScreen(screen: NavKey) { coroutineScope.launch { mutableOpenScreenFlow.emit(screen) } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/notification/FollowNotification.kt ================================================ package com.zhangke.fread.commonbiz.shared.notification import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.zhangke.fread.commonbiz.shared.composable.UserInfoCard import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.notification.StatusNotification import org.jetbrains.compose.resources.stringResource @Composable fun FollowNotification( notification: StatusNotification.Follow, style: NotificationStyle, onUserInfoClick: (BlogAuthor) -> Unit, onFollowAccountClick: (BlogAuthor) -> Unit, onUnfollowAccountClick: (BlogAuthor) -> Unit, onUnblockClick: (BlogAuthor) -> Unit, onCancelFollowRequestClick: (BlogAuthor) -> Unit, ) { Column( modifier = Modifier.fillMaxWidth() .padding(style.containerPaddings), ) { NotificationHeadLine( modifier = Modifier.fillMaxWidth(), icon = Icons.Default.PersonAdd, avatar = notification.author.avatar, createAt = notification.formattingDisplayTime, accountName = notification.author.humanizedName, interactionDesc = stringResource(LocalizedString.sharedNotificationFollowDesc), style = style, ) UserInfoCard( modifier = Modifier.padding(top = style.headLineToContentPadding) .fillMaxWidth(), user = notification.author, onUserClick = onUserInfoClick, onFollowAccountClick = onFollowAccountClick, onUnfollowAccountClick = onUnfollowAccountClick, onUnblockClick = onUnblockClick, onCancelFollowRequestClick = onCancelFollowRequestClick, ) } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/notification/FollowRequestNotification.kt ================================================ package com.zhangke.fread.commonbiz.shared.notification import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.PersonAddAlt1 import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.notification.StatusNotification import com.zhangke.fread.status.ui.BlogAuthorAvatar import com.zhangke.fread.status.ui.richtext.FreadRichText import org.jetbrains.compose.resources.stringResource @Composable fun FollowRequestNotification( notification: StatusNotification.FollowRequest, style: NotificationStyle, onUserInfoClick: (BlogAuthor) -> Unit, onRejectClick: (BlogAuthor) -> Unit, onAcceptClick: (BlogAuthor) -> Unit, ) { Column( modifier = Modifier.fillMaxWidth() .padding(style.containerPaddings) .padding(bottom = 16.dp) ) { NotificationHeadLine( modifier = Modifier.clickable { onUserInfoClick(notification.author) }, icon = Icons.Default.PersonAddAlt1, createAt = notification.formattingDisplayTime, avatar = notification.author.avatar, accountName = null, interactionDesc = stringResource(LocalizedString.sharedNotificationFollowRequest), style = style, ) Row( modifier = Modifier.fillMaxWidth() .padding(top = style.headLineToContentPadding), verticalAlignment = Alignment.CenterVertically, ) { BlogAuthorAvatar( modifier = Modifier .size(style.statusStyle.infoLineStyle.avatarSize), imageUrl = notification.author.avatar, ) Column( modifier = Modifier.weight(1F).padding(start = 6.dp, end = 6.dp), ) { FreadRichText( modifier = Modifier, richText = notification.author.humanizedName, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( modifier = Modifier.padding(top = 2.dp), textAlign = TextAlign.Left, text = notification.author.webFinger.toString(), maxLines = 1, overflow = TextOverflow.Ellipsis, ) } SimpleIconButton( modifier = Modifier .size(32.dp), colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.primaryContainer, ), onClick = { onRejectClick(notification.author) }, imageVector = Icons.Default.Clear, contentDescription = "Reject", ) Spacer(Modifier.width(8.dp)) SimpleIconButton( modifier = Modifier.size(32.dp), colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.primaryContainer, ), onClick = { onAcceptClick(notification.author) }, imageVector = Icons.Default.Check, contentDescription = "Accept", ) } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/notification/NotificationHeadLine.kt ================================================ package com.zhangke.fread.commonbiz.shared.notification import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.zhangke.framework.composable.TwoTextsInRow import com.zhangke.fread.status.model.FormattingTime import com.zhangke.fread.status.richtext.RichText import com.zhangke.fread.status.ui.BlogAuthorAvatar import com.zhangke.fread.status.ui.richtext.FreadRichText @Composable fun NotificationHeadLine( modifier: Modifier, icon: ImageVector, avatar: String?, accountName: RichText?, interactionDesc: String, style: NotificationStyle, createAt: FormattingTime, iconTint: Color = LocalContentColor.current, ) { Row( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Icon( modifier = Modifier.size(style.typeLogoSize), imageVector = icon, contentDescription = null, tint = iconTint, ) if (!avatar.isNullOrEmpty()) { BlogAuthorAvatar( modifier = Modifier .padding(start = 6.dp) .size(style.triggerAccountAvatarSize), imageUrl = avatar, ) } TwoTextsInRow( modifier = Modifier.weight(1F), firstText = { FreadRichText( modifier = Modifier.padding(start = 6.dp), richText = accountName ?: RichText.empty, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 12.sp, ) }, secondText = { Text( modifier = Modifier, text = interactionDesc, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelMedium, ) }, spacing = 2.dp, ) Text( modifier = Modifier.padding(start = 4.dp), text = createAt.formattedTime(), maxLines = 1, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, ) } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/notification/NotificationWithWholeStatus.kt ================================================ package com.zhangke.fread.commonbiz.shared.notification import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import com.zhangke.fread.commonbiz.shared.composable.BlogUi import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.model.FormattingTime import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.ui.ComposedStatusInteraction @Composable fun NotificationWithWholeStatus( blog: Blog, locator: PlatformLocator, author: BlogAuthor?, createAt: FormattingTime, indexInList: Int, sharedElementId: String, icon: ImageVector, interactionDesc: String, style: NotificationStyle, composedStatusInteraction: ComposedStatusInteraction, iconTint: Color = LocalContentColor.current, ) { Column( modifier = Modifier .clickable { composedStatusInteraction.onBlogClick(locator, blog) } .fillMaxWidth() .padding(style.containerPaddings) ) { NotificationHeadLine( modifier = Modifier.clickable(enabled = author != null) { composedStatusInteraction.onUserInfoClick(locator, author!!) }, icon = icon, avatar = author?.avatar, iconTint = iconTint, createAt = createAt, accountName = author?.humanizedName, interactionDesc = interactionDesc, style = style, ) BlogUi( modifier = Modifier.padding(top = style.headLineToContentPadding) .statusBorder() .padding(style.internalBlogPadding), blog = blog, locator = locator, indexInList = indexInList, sharedElementId = sharedElementId, style = style.statusStyle.copy( containerStartPadding = 0.dp, containerEndPadding = 0.dp, containerTopPadding = 0.dp, containerBottomPadding = 0.dp, ), showBottomPanel = false, showMoreOperationIcon = false, composedStatusInteraction = composedStatusInteraction, ) } } @Composable fun Modifier.statusBorder(show: Boolean = true): Modifier { return if (show) { this.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(4.dp)) } else { this } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/notification/SeveredRelationshipsNotification.kt ================================================ package com.zhangke.fread.commonbiz.shared.notification import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.notification.StatusNotification import org.jetbrains.compose.resources.stringResource @Composable fun SeveredRelationshipsNotification( notification: StatusNotification.SeveredRelationships, style: NotificationStyle, onUserInfoClick: (BlogAuthor) -> Unit, ) { Column( modifier = Modifier .fillMaxWidth() .clickable { onUserInfoClick(notification.author) } .padding(style.containerPaddings), ) { NotificationHeadLine( modifier = Modifier, icon = Icons.Default.WarningAmber, avatar = notification.author.avatar, createAt = notification.formattingDisplayTime, accountName = notification.author.humanizedName, interactionDesc = stringResource(LocalizedString.sharedNotificationSeveredDesc), style = style, ) Text( modifier = Modifier .padding(top = style.headLineToContentPadding) .align(Alignment.CenterHorizontally), text = notification.reason, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/notification/StatusNotificationUi.kt ================================================ package com.zhangke.fread.commonbiz.shared.notification import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.NotificationsNone import androidx.compose.material.icons.filled.Poll import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.zhangke.fread.commonbiz.shared.composable.FeedsStatusNode import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.notification.StatusNotification import com.zhangke.fread.status.ui.ComposedStatusInteraction import com.zhangke.fread.status.ui.action.quoteIcon import com.zhangke.fread.status.ui.action.quoteInLeftIcon import com.zhangke.fread.status.ui.style.LocalStatusUiConfig import com.zhangke.fread.status.ui.style.StatusStyle import com.zhangke.fread.statusui.ic_status_forward import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource @Composable fun StatusNotificationUi( modifier: Modifier, notification: StatusNotification, indexInList: Int, style: NotificationStyle = defaultNotificationStyle(), onRejectClick: (BlogAuthor) -> Unit, onAcceptClick: (BlogAuthor) -> Unit, onUnblockClick: (PlatformLocator, BlogAuthor) -> Unit, onCancelFollowRequestClick: (PlatformLocator, BlogAuthor) -> Unit, composedStatusInteraction: ComposedStatusInteraction, ) { Column(modifier = modifier) { Box(modifier = Modifier) { when (notification) { is StatusNotification.Like -> { NotificationWithWholeStatus( blog = notification.blog, locator = notification.locator, author = notification.author, createAt = notification.formattingDisplayTime, indexInList = indexInList, sharedElementId = notification.id, icon = Icons.Default.Favorite, iconTint = MaterialTheme.colorScheme.tertiary, interactionDesc = stringResource(LocalizedString.sharedNotificationFavouritedDesc), style = style, composedStatusInteraction = composedStatusInteraction, ) } is StatusNotification.Mention -> { FeedsStatusNode( modifier = Modifier.fillMaxWidth(), status = notification.status, indexInList = indexInList, sharedElementId = notification.id, style = style.statusStyle, showDivider = false, composedStatusInteraction = composedStatusInteraction, ) } is StatusNotification.Reply -> { FeedsStatusNode( modifier = Modifier.fillMaxWidth(), status = notification.status, indexInList = indexInList, sharedElementId = notification.id, style = style.statusStyle, showDivider = false, composedStatusInteraction = composedStatusInteraction, ) } is StatusNotification.Repost -> { NotificationWithWholeStatus( blog = notification.blog, locator = notification.locator, author = notification.author, createAt = notification.formattingDisplayTime, indexInList = indexInList, sharedElementId = notification.id, icon = vectorResource(com.zhangke.fread.statusui.Res.drawable.ic_status_forward), interactionDesc = stringResource(LocalizedString.sharedNotificationReblogDesc), style = style, composedStatusInteraction = composedStatusInteraction, ) } is StatusNotification.Poll -> { NotificationWithWholeStatus( blog = notification.blog, locator = notification.locator, createAt = notification.formattingDisplayTime, author = null, indexInList = indexInList, sharedElementId = notification.id, icon = Icons.Default.Poll, interactionDesc = stringResource(LocalizedString.sharedNotificationPollDesc), style = style, composedStatusInteraction = composedStatusInteraction, ) } is StatusNotification.Follow -> { FollowNotification( notification = notification, style = style, onUserInfoClick = { composedStatusInteraction.onUserInfoClick(notification.locator, it) }, onFollowAccountClick = { composedStatusInteraction.onFollowClick(notification.locator, it) }, onUnblockClick = { onUnblockClick(notification.locator, it) }, onUnfollowAccountClick = { composedStatusInteraction.onUnfollowClick(notification.locator, it) }, onCancelFollowRequestClick = { onCancelFollowRequestClick(notification.locator, it) }, ) } is StatusNotification.Update -> { NotificationWithWholeStatus( blog = notification.status.status.intrinsicBlog, locator = notification.locator, author = notification.status.status.triggerAuthor, createAt = notification.formattingDisplayTime, indexInList = indexInList, sharedElementId = notification.id, icon = Icons.Default.Edit, interactionDesc = stringResource(LocalizedString.sharedNotificationUpdateDesc), style = style, composedStatusInteraction = composedStatusInteraction, ) } is StatusNotification.FollowRequest -> { FollowRequestNotification( notification = notification, style = style, onUserInfoClick = { composedStatusInteraction.onUserInfoClick(notification.locator, it) }, onRejectClick = onRejectClick, onAcceptClick = onAcceptClick, ) } is StatusNotification.NewStatus -> { NotificationWithWholeStatus( blog = notification.status.status.intrinsicBlog, locator = notification.locator, author = notification.status.status.triggerAuthor, createAt = notification.formattingDisplayTime, indexInList = indexInList, sharedElementId = notification.id, icon = Icons.Default.NotificationsNone, interactionDesc = stringResource(LocalizedString.sharedNotificationNewStatusDesc), style = style, composedStatusInteraction = composedStatusInteraction, ) } is StatusNotification.Quote -> { NotificationWithWholeStatus( blog = notification.status.status.intrinsicBlog, locator = notification.locator, author = notification.status.status.triggerAuthor, createAt = notification.formattingDisplayTime, indexInList = indexInList, sharedElementId = notification.id, icon = quoteInLeftIcon(), interactionDesc = stringResource(LocalizedString.sharedNotificationQuoteDesc), style = style, composedStatusInteraction = composedStatusInteraction, ) } is StatusNotification.QuoteUpdate -> { NotificationWithWholeStatus( blog = notification.status.status.intrinsicBlog, locator = notification.locator, author = notification.author, createAt = notification.formattingDisplayTime, indexInList = indexInList, sharedElementId = notification.id, icon = Icons.Default.Edit, interactionDesc = stringResource(LocalizedString.sharedNotificationUpdateDesc), style = style, composedStatusInteraction = composedStatusInteraction, ) } is StatusNotification.SeveredRelationships -> { SeveredRelationshipsNotification( notification = notification, style = style, onUserInfoClick = { composedStatusInteraction.onUserInfoClick(notification.locator, it) }, ) } is StatusNotification.Unknown -> { UnknownNotification(notification = notification) } } } } } data class NotificationStyle( val nameMaxLength: Int, /** * 通知UI整体的外部边距 */ val containerPaddings: PaddingValues, /** * 通知内部的博文正文和边框之间的边距 */ val internalBlogPadding: PaddingValues, /** * 通知标题到博客正文之间的边距 */ val headLineToContentPadding: Dp, val statusStyle: StatusStyle, /** * 通知触发者的头像大小 */ val triggerAccountAvatarSize: Dp, val typeLogoSize: Dp, ) object NotificationStyleDefaults { const val nameMaxLength = 10 val containerStartPadding = 16.dp val containerTopPadding = 8.dp val containerEndPadding = 16.dp val containerBottomPadding = 8.dp val internalBlogStartPadding = 8.dp val internalBlogTopPadding = 8.dp val internalBlogEndPadding = 8.dp val internalBlogBottomPadding = 8.dp val headLineToContentPadding = 6.dp val triggerAccountAvatarSize = 26.dp val typeLogoSize = 20.dp } @Composable fun defaultNotificationStyle( nameMaxLength: Int = NotificationStyleDefaults.nameMaxLength, containerPaddings: PaddingValues = PaddingValues( start = NotificationStyleDefaults.containerStartPadding, top = NotificationStyleDefaults.containerTopPadding, end = NotificationStyleDefaults.containerEndPadding, bottom = NotificationStyleDefaults.containerBottomPadding, ), internalBlogPadding: PaddingValues = PaddingValues( start = NotificationStyleDefaults.internalBlogStartPadding, top = NotificationStyleDefaults.internalBlogTopPadding, end = NotificationStyleDefaults.internalBlogEndPadding, bottom = NotificationStyleDefaults.internalBlogBottomPadding, ), headLineToContentPadding: Dp = NotificationStyleDefaults.headLineToContentPadding, statusStyle: StatusStyle = LocalStatusUiConfig.current.contentStyle, triggerAccountAvatarSize: Dp = NotificationStyleDefaults.triggerAccountAvatarSize, typeLogoSize: Dp = NotificationStyleDefaults.typeLogoSize, ) = NotificationStyle( nameMaxLength = nameMaxLength, containerPaddings = containerPaddings, internalBlogPadding = internalBlogPadding, headLineToContentPadding = headLineToContentPadding, statusStyle = statusStyle, triggerAccountAvatarSize = triggerAccountAvatarSize, typeLogoSize = typeLogoSize, ) ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/notification/UnknownNotification.kt ================================================ package com.zhangke.fread.commonbiz.shared.notification import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.notification.StatusNotification import org.jetbrains.compose.resources.stringResource @Composable fun UnknownNotification( notification: StatusNotification.Unknown, ) { Column( modifier = Modifier .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), text = stringResource(LocalizedString.sharedNotificationUnknownDesc), ) Text( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), text = notification.message, maxLines = 3, overflow = TextOverflow.Ellipsis, ) } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/repo/SelectedAccountPublishingRepo.kt ================================================ package com.zhangke.fread.commonbiz.shared.repo import com.zhangke.fread.commonbiz.shared.db.SelectedAccountPublishing import com.zhangke.fread.commonbiz.shared.db.SelectedAccountPublishingDatabase class SelectedAccountPublishingRepo ( private val database: SelectedAccountPublishingDatabase, ) { suspend fun getAll(): List { return database.dao().getAll().map { it.accountUri } } suspend fun replace(items: List) { database.dao().let { it.deleteTable() it.insert(items.map { SelectedAccountPublishing(it) }) } } suspend fun deleteTable() { database.dao().deleteTable() } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/ImageViewerScreen.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.seiko.imageloader.LocalImageLoader import com.seiko.imageloader.model.ImageRequest import com.seiko.imageloader.model.ImageResult import com.seiko.imageloader.option.SizeResolver import com.seiko.imageloader.rememberImagePainter import com.zhangke.framework.blurhash.blurhash import com.zhangke.framework.composable.HorizontalPageIndicator import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.image.viewer.ImageViewer import com.zhangke.framework.composable.image.viewer.ImageViewerDefault import com.zhangke.framework.composable.image.viewer.rememberImageViewerState import com.zhangke.framework.composable.rememberTransientModalBottomSheetState import com.zhangke.framework.imageloader.executeSafety import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.popIfNotRoot import com.zhangke.framework.nav.sharedElement import com.zhangke.framework.permission.RequireLocalStoragePermission import com.zhangke.framework.utils.PlatformSerializable import com.zhangke.fread.common.utils.LocalMediaFileHelper import kotlinx.coroutines.delay import kotlinx.serialization.Serializable @Serializable data class ImageViewerScreenNavKey( val selectedIndex: Int, val imageList: List, ) : NavKey @Composable fun ImageViewerScreen( selectedIndex: Int, imageList: List, ) { val backStack = LocalNavBackStack.currentOrThrow val backgroundCommonAlpha = 0.95F if (imageList.isEmpty()) { LaunchedEffect(imageList) { backStack.popIfNotRoot() } return } var backgroundColorAlpha by remember { mutableFloatStateOf(0F) } var showIndicator by remember { mutableStateOf(false) } LaunchedEffect(Unit) { Animatable(0F).animateTo( targetValue = backgroundCommonAlpha, animationSpec = tween( ImageViewerDefault.ANIMATION_DURATION, easing = FastOutSlowInEasing ), ) { backgroundColorAlpha = value } } Box( modifier = Modifier.fillMaxSize(), ) { Canvas(modifier = Modifier.fillMaxSize()) { drawRect( color = Color.Black, alpha = backgroundColorAlpha, ) } val pagerState = rememberPagerState( initialPage = selectedIndex.coerceAtLeast(0), pageCount = imageList::size, ) Box(modifier = Modifier.fillMaxSize()) { HorizontalPager( modifier = Modifier .fillMaxSize(), state = pagerState, ) { pageIndex -> val currentMedia = imageList[pageIndex] ImagePageContent( image = currentMedia, onDismissRequest = backStack::popIfNotRoot, ) } ImageTopBar(imageList[pagerState.currentPage]) LaunchedEffect(Unit) { delay(300) showIndicator = true } if (showIndicator && pagerState.pageCount > 1) { HorizontalPageIndicator( currentIndex = pagerState.currentPage, pageCount = pagerState.pageCount, modifier = Modifier .padding(bottom = 64.dp) .align(Alignment.BottomCenter) .fillMaxWidth(), ) } } } } @Composable private fun ImagePageContent( image: ImageViewerImage, onDismissRequest: () -> Unit, ) { val imageLoader = LocalImageLoader.current var aspectRatio: Float? by remember { mutableStateOf(image.aspect) } if (aspectRatio == null) { LaunchedEffect(image) { val request = ImageRequest { data(image.url) size(SizeResolver(Size(50f, 50f))) } aspectRatio = imageLoader.executeSafety(request).aspectRatio() ?: 1F } } if (aspectRatio != null) { val viewerState = rememberImageViewerState( aspectRatio = aspectRatio!!, onDismissRequest = onDismissRequest, ) ImageViewer( state = viewerState, modifier = Modifier.fillMaxSize(), ) { val request = remember(image.url) { ImageRequest(image.url) } Image( painter = rememberImagePainter(request = request), modifier = Modifier .fillMaxSize() .blurhash(image.blurhash) .let { if (image.sharedElementKey.isNullOrEmpty()) { it } else { it.sharedElement(image.sharedElementKey) } }, contentScale = ContentScale.FillBounds, contentDescription = image.description, ) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun BoxScope.ImageTopBar(image: ImageViewerImage) { var showBottomSheet by remember { mutableStateOf(false) } val mediaFileHelper = LocalMediaFileHelper.current Row( modifier = Modifier .align(Alignment.TopEnd) .statusBarsPadding() .padding(top = 8.dp, end = 8.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { var needSaveImage by remember { mutableStateOf(false) } if (needSaveImage) { RequireLocalStoragePermission( onPermissionGranted = { mediaFileHelper.saveImageToGallery(image.url) needSaveImage = false }, onPermissionDenied = { needSaveImage = false }, ) } Toolbar.DownloadButton( onClick = { needSaveImage = true }, tint = Color.White.copy(alpha = 0.7F), ) if (!image.description.isNullOrEmpty()) { Spacer(modifier = Modifier.width(16.dp)) SimpleIconButton( onClick = { showBottomSheet = true }, tint = Color.White.copy(alpha = 0.7F), imageVector = Icons.Default.Info, contentDescription = "Image description", ) if (showBottomSheet) { val sheetState = rememberTransientModalBottomSheetState() ModalBottomSheet( sheetState = sheetState, onDismissRequest = { showBottomSheet = false }, ) { Box( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 28.dp) .wrapContentHeight() ) { Text(text = image.description) } } } } } } @Serializable data class ImageViewerImage( val url: String, val previewUrl: String? = null, val description: String? = null, val blurhash: String? = null, val aspect: Float? = null, val sharedElementKey: String? = null, ) : PlatformSerializable internal expect fun ImageResult.aspectRatio(): Float? ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/SelectLanguageScreen.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.ScreenEventFlow import com.zhangke.framework.utils.LanguageUtils import com.zhangke.framework.utils.Locale import com.zhangke.framework.utils.getDisplayName import com.zhangke.framework.utils.languageCode import com.zhangke.fread.localization.LocalizedString import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable data class SelectLanguageScreenNavKey( val selectedLanguages: List = emptyList(), val maxSelectCount: Int = 1, ) : NavKey { companion object { val selectedFlow = ScreenEventFlow>() } } @Composable fun SelectLanguageScreen( selectedLanguages: List = emptyList(), maxSelectCount: Int = 1, ) { val backStack = LocalNavBackStack.currentOrThrow val multipleSelection = maxSelectCount > 1 val languageList = remember { val list = LanguageUtils.getAllLanguages().map { language -> val selected = selectedLanguages.any { language.languageCode == it } LanguageUiState(language, selected) } mutableStateListOf(*list.toTypedArray()) } val coroutineScope = rememberCoroutineScope() fun onLanguageSelected(languageUiState: LanguageUiState) { if (!multipleSelection) { coroutineScope.launch { SelectLanguageScreenNavKey.selectedFlow.emit(listOf(languageUiState.local.languageCode)) backStack.removeLastOrNull() } return } if (languageUiState.selected) { languageList[languageList.indexOf(languageUiState)] = languageUiState.copy(selected = false) } else { if (languageList.count { it.selected } < maxSelectCount) { languageList[languageList.indexOf(languageUiState)] = languageUiState.copy(selected = true) } } } Scaffold( topBar = { var toolbarVisible by remember { mutableStateOf(true) } AnimatedVisibility( visible = toolbarVisible, enter = fadeIn(), exit = fadeOut(), ) { Toolbar( title = stringResource(LocalizedString.sharedSelectLanguageTitle), onBackClick = backStack::removeLastOrNull, actions = { IconButton( onClick = { toolbarVisible = false }, ) { Icon( imageVector = Icons.Default.Search, contentDescription = stringResource(LocalizedString.search), ) } if (multipleSelection) { IconButton( onClick = { coroutineScope.launch { SelectLanguageScreenNavKey.selectedFlow .emit(languageList.filter { it.selected } .map { it.local.languageCode }) backStack.removeLastOrNull() } }, ) { Icon( imageVector = Icons.Default.Check, contentDescription = stringResource(LocalizedString.ok), ) } } } ) } var query by rememberSaveable { mutableStateOf("") } AnimatedVisibility( visible = !toolbarVisible, enter = fadeIn(), exit = fadeOut(), ) { SearchLanguageBar( query = query, onQueryChanged = { query = it }, list = languageList, onClose = { toolbarVisible = true }, onLanguageClicked = { onLanguageSelected(it) }, ) } }, ) { paddingValues -> LazyColumn( modifier = Modifier .padding(paddingValues) .fillMaxWidth(), ) { if (languageList.count { it.selected } > 0) { stickyHeader { LazyRow( verticalAlignment = Alignment.CenterVertically, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 6.dp), ) { languageList.filter { it.selected } .reversed() .forEach { languageUiState -> item { SelectedLanguageItem( languageUiState = languageUiState, onRemoveClick = { languageList[languageList.indexOf(languageUiState)] = languageUiState.copy(selected = false) } ) } item { Spacer(modifier = Modifier.width(16.dp)) } } } } } items(languageList) { languageUiState -> LanguageItem( languageUiState = languageUiState, onLanguageClicked = { onLanguageSelected(it) }, ) } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SearchLanguageBar( query: String, onQueryChanged: (String) -> Unit, onClose: () -> Unit, list: List, onLanguageClicked: (LanguageUiState) -> Unit, ) { val currentLanguageList = remember { mutableStateListOf(*list.filterByQuery(query).toTypedArray()) } SearchBar( modifier = Modifier.fillMaxWidth().systemBarsPadding(), expanded = true, windowInsets = WindowInsets.statusBars, onExpandedChange = { onClose() }, inputField = { SearchBarDefaults.InputField( query = query, onQueryChange = { q -> onQueryChanged(q) currentLanguageList.clear() if (q.isNotEmpty()) { currentLanguageList += list.filterByQuery(q) } }, onSearch = {}, expanded = true, onExpandedChange = { if (!it) { onClose() } }, leadingIcon = { Toolbar.BackButton(onBackClick = onClose) }, ) }, ) { LazyColumn(modifier = Modifier.fillMaxSize().imePadding()) { items(currentLanguageList) { language -> LanguageItem( languageUiState = language, onLanguageClicked = { onLanguageClicked(it) onClose() }, ) } } } } private fun List.filterByQuery(query: String): List { if (query.isEmpty()) return emptyList() return this.filter { languageUiState -> val displayName = languageUiState.local.getDisplayName(languageUiState.local).lowercase() val lowercaseQuery = query.lowercase() displayName.contains(lowercaseQuery) || lowercaseQuery.contains(displayName) } } @Composable private fun LanguageItem( languageUiState: LanguageUiState, onLanguageClicked: (LanguageUiState) -> Unit, ) { Box( modifier = Modifier .fillMaxWidth() .height(60.dp) .clickable { onLanguageClicked(languageUiState) }, ) { Box( modifier = Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp), ) { Text( modifier = Modifier .align(Alignment.CenterStart), text = languageUiState.local.getDisplayName(languageUiState.local), ) if (languageUiState.selected) { Icon( modifier = Modifier .align(Alignment.CenterEnd), imageVector = Icons.Default.Check, contentDescription = "Checked", tint = MaterialTheme.colorScheme.primary, ) } } } } @Composable private fun SelectedLanguageItem(languageUiState: LanguageUiState, onRemoveClick: () -> Unit) { Box( modifier = Modifier, ) { Card( elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), onClick = onRemoveClick, shape = RoundedCornerShape(50), ) { Row( modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = Modifier, text = languageUiState.local.getDisplayName(languageUiState.local), style = MaterialTheme.typography.labelMedium, ) Spacer(modifier = Modifier.size(2.dp)) Icon( modifier = Modifier.size(14.dp), imageVector = Icons.Default.Clear, contentDescription = "Remove", ) } } } } data class LanguageUiState( val local: Locale, val selected: Boolean, ) ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/PublishBlogScreen.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.TextFieldValue import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.fread.localization.LocalizedString import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable object PublishBlogScreenNavKey : NavKey @Composable fun PublishBlogScreen() { val snackbarHostState = rememberSnackbarHostState() // PublishBlogContent( // snackbarHostState = snackbarHostState, // onBackClick = navigator::pop, // ) } @Composable private fun PublishBlogContent( uiState: PublishBlogUiState, onContentChanged: (TextFieldValue) -> Unit, snackbarHostState: SnackbarHostState, onBackClick: () -> Unit, ) { Scaffold( topBar = { Toolbar( title = stringResource(LocalizedString.sharedPublishBlogTitle), onBackClick = onBackClick, ) }, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { innerPadding -> Column( modifier = Modifier.fillMaxSize().padding(innerPadding), ) { // InputBlogTextField( // modifier = Modifier // .fillMaxWidth() // .padding(horizontal = 8.dp), // textFieldValue = uiState.content, // onContentChanged = onContentChanged, // ) } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/PublishBlogUiState.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish import androidx.compose.ui.text.input.TextFieldValue data class PublishBlogUiState( val content: TextFieldValue, ) ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/PublishPostBottomPanel.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.SmartDisplay import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.pick.PickVisualMediaLauncherContainer import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.utils.PlatformUri import com.zhangke.framework.utils.getDisplayName import com.zhangke.framework.utils.initLocale import com.zhangke.fread.commonbiz.shared.screen.SelectLanguageScreenNavKey import com.zhangke.fread.status.ui.common.RemainingTextStatus import com.zhangke.fread.statusui.ic_post_status_spoiler import org.jetbrains.compose.resources.painterResource @Composable fun Modifier.bottomPaddingAsBottomBar(): Modifier { val bottomPaddingByIme = WindowInsets.ime.asPaddingValues().calculateBottomPadding() return if (bottomPaddingByIme > 0.dp) { this.padding(bottom = bottomPaddingByIme) } else { this.navigationBarsPadding() } } @Composable fun PublishPostFeaturesPanel( modifier: Modifier, contentLength: Int, maxContentLimit: Int, mediaAvailableCount: Int, onMediaSelected: (List) -> Unit, selectedLanguages: List, maxLanguageCount: Int, onLanguageSelected: (List) -> Unit, mediaSelectEnabled: Boolean = true, containerColor: Color = MaterialTheme.colorScheme.surface, actions: @Composable RowScope.() -> Unit = {}, floatingBar: @Composable () -> Unit = {}, ) { Surface( modifier = modifier.fillMaxWidth(), color = containerColor, ) { Column(modifier = Modifier.fillMaxWidth()) { floatingBar.invoke() Row( modifier = Modifier.fillMaxWidth() .padding(start = 8.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { PickVisualMediaLauncherContainer( onResult = onMediaSelected, maxItems = mediaAvailableCount, ) { val enabled = mediaSelectEnabled && mediaAvailableCount > 0 SimpleIconButton( modifier = Modifier, onClick = { launchImage() }, onLongClick = { launchImageFile() }, enabled = enabled, imageVector = Icons.Default.Image, contentDescription = "Add Image", ) } PickVisualMediaLauncherContainer( onResult = onMediaSelected, maxItems = 1, ) { val enabled = mediaSelectEnabled && mediaAvailableCount > 0 SimpleIconButton( modifier = Modifier, onClick = { launchVideo() }, onLongClick = { launchVideoFile() }, enabled = enabled, imageVector = Icons.Default.SmartDisplay, contentDescription = "Add Video", ) } actions() Spacer(modifier = Modifier.weight(1F)) SelectLanguageIconButton( modifier = Modifier, onLanguageSelected = onLanguageSelected, selectedLanguages = selectedLanguages, maxLanguageCount = maxLanguageCount, ) RemainingTextStatus( modifier = Modifier, maxCount = maxContentLimit, contentLength = contentLength, ) } } } } @Composable private fun SelectLanguageIconButton( modifier: Modifier, selectedLanguages: List, maxLanguageCount: Int, onLanguageSelected: (List) -> Unit, ) { val backStack = LocalNavBackStack.currentOrThrow ConsumeFlow(SelectLanguageScreenNavKey.selectedFlow.flow) { onLanguageSelected(it) } fun selectLanguage() { backStack.add( SelectLanguageScreenNavKey( selectedLanguages = selectedLanguages, maxSelectCount = maxLanguageCount, ) ) } Box(modifier = modifier) { if (selectedLanguages.isEmpty()) { SimpleIconButton( onClick = { selectLanguage() }, imageVector = Icons.Default.Language, contentDescription = "Choose language", ) } else { TextButton( onClick = { selectLanguage() }, ) { val languages = remember(selectedLanguages) { selectedLanguages.map { initLocale(it) } .joinToString { it.getDisplayName(it) } } Text( text = languages, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) } } } } @Composable fun SensitiveIconButton(onSensitiveClick: () -> Unit) { IconButton( onClick = onSensitiveClick, modifier = Modifier.padding(start = 4.dp), ) { Box(modifier = Modifier.size(29.dp)) { Icon( modifier = Modifier.size(24.dp).align(Alignment.TopCenter), painter = painterResource(com.zhangke.fread.statusui.Res.drawable.ic_post_status_spoiler), contentDescription = "Sensitive content", ) } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/PublishPostMediaAttachment.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish import androidx.compose.foundation.background import androidx.compose.foundation.border 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.aspectRatio 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.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults 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.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.seiko.imageloader.ui.AutoSizeImage import com.zhangke.framework.composable.Grid import com.zhangke.framework.composable.noRippleClick import com.zhangke.framework.composable.rememberTransientModalBottomSheetState import com.zhangke.framework.utils.transparentIndicatorColors import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.ui.common.RemainingTextStatus import org.jetbrains.compose.resources.stringResource @Composable fun PublishPostMediaAttachment( modifier: Modifier, medias: List, mediaAltMaxCharacters: Int, onAltChanged: (PublishPostMedia, String) -> Unit, onDeleteClick: (PublishPostMedia) -> Unit, ) { Grid( modifier = modifier, columnCount = 2, verticalSpacing = 16.dp, horizontalSpacing = 16.dp, ) { medias.forEach { media -> PublishPostMediaAttachmentImage( modifier = Modifier.fillMaxWidth().aspectRatio(1F), image = media, isVideo = media.isVideo, mediaAltMaxCharacters = mediaAltMaxCharacters, onAltChanged = onAltChanged, onDeleteClick = onDeleteClick, ) } } } @Composable private fun PublishPostMediaAttachmentImage( modifier: Modifier, image: PublishPostMedia, isVideo: Boolean, mediaAltMaxCharacters: Int, onAltChanged: (PublishPostMedia, String) -> Unit, onDeleteClick: (PublishPostMedia) -> Unit, ) { val shadowColor = Color.Black.copy(alpha = 0.7F) val fontColor = Color.White var showAltDialog by remember { mutableStateOf(false) } Box(modifier = modifier) { AutoSizeImage( url = image.uri, modifier = Modifier.fillMaxSize() .clip(RoundedCornerShape(6.dp)) .border( width = 1.dp, color = MaterialTheme.colorScheme.outlineVariant, shape = RoundedCornerShape(6.dp), ), contentDescription = image.alt, contentScale = ContentScale.Crop, ) Row( modifier = Modifier.padding(start = 8.dp, top = 8.dp) .background( color = shadowColor, shape = RoundedCornerShape(2.dp), ).padding(start = 2.dp, top = 2.dp, bottom = 2.dp, end = 2.dp) .noRippleClick { showAltDialog = true }, verticalAlignment = Alignment.CenterVertically, ) { val iconVector = if (image.alt.isNullOrEmpty()) { Icons.Default.Add } else { Icons.Default.Check } Icon( modifier = Modifier.size(14.dp), imageVector = iconVector, contentDescription = if (image.alt.isNullOrEmpty()) { "Add ALT" } else { "ALT Added" }, tint = fontColor, ) Spacer(modifier = Modifier.width(1.dp)) Text( text = stringResource(LocalizedString.sharedAltLabel), color = fontColor, style = MaterialTheme.typography.labelSmall, ) } Box( modifier = Modifier.noRippleClick { onDeleteClick(image) } .align(Alignment.TopEnd) .padding(top = 8.dp, end = 8.dp) .background( color = shadowColor, shape = CircleShape, ).padding(4.dp), ) { Icon( modifier = Modifier.size(14.dp), imageVector = Icons.Default.Close, contentDescription = "Delete", tint = fontColor, ) } if (isVideo) { Box( modifier = Modifier.align(Alignment.Center) .background( color = shadowColor, shape = CircleShape, ).padding(1.dp), ) { Icon( modifier = Modifier.size(24.dp), imageVector = Icons.Default.PlayArrow, contentDescription = null, tint = fontColor, ) } } } if (showAltDialog) { PublishPostImageAltDialog( imageUri = image.uri, onDismissRequest = { showAltDialog = false }, alt = image.alt.orEmpty(), maxCharacters = mediaAltMaxCharacters, onAltChanged = { onAltChanged(image, it) }, ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun PublishPostImageAltDialog( imageUri: String, onDismissRequest: () -> Unit, alt: String, maxCharacters: Int, onAltChanged: (String) -> Unit, ) { val sheetState = rememberTransientModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, ) { Column( modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 24.dp), ) { Text( text = stringResource(LocalizedString.sharedPublishMediaAltDialogTitle), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), ) Card( modifier = Modifier.padding(top = 6.dp) .fillMaxWidth() .aspectRatio(1.7F), ) { Box( modifier = Modifier.fillMaxSize(), ) { AutoSizeImage( url = imageUri, modifier = Modifier.align(Alignment.Center), contentDescription = null, ) } } Text( modifier = Modifier.padding(top = 16.dp), text = stringResource(LocalizedString.sharedPublishMediaAltDialogInputTip), style = MaterialTheme.typography.labelMedium, ) var inputtedValue by remember(alt) { mutableStateOf(alt) } TextField( modifier = Modifier.padding(top = 8.dp) .fillMaxWidth() .border( width = 1.dp, color = MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(16.dp), ), value = inputtedValue, onValueChange = { inputtedValue = it }, minLines = 1, placeholder = { Text(text = stringResource(LocalizedString.sharedPublishMediaAltDialogInputHint)) }, colors = TextFieldDefaults.transparentIndicatorColors.copy( focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, ), ) Row( modifier = Modifier.fillMaxWidth() .padding(top = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { RemainingTextStatus( modifier = Modifier.padding(start = 16.dp), maxCount = maxCharacters, contentLength = inputtedValue.length, ) Button( modifier = Modifier.padding(start = 16.dp).weight(1F), onClick = { onAltChanged(inputtedValue) onDismissRequest() }, ) { Text(text = stringResource(LocalizedString.save)) } } } } } interface PublishPostMedia { val uri: String val alt: String? val isVideo: Boolean } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/PublishPostScaffold.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.seiko.imageloader.ui.AutoSizeImage import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.keyboardAsState import com.zhangke.framework.utils.HighlightTextBuildUtil import com.zhangke.framework.utils.pxToDp import com.zhangke.framework.utils.toPx import com.zhangke.fread.commonbiz.shared.screen.publish.composable.InputBlogTextField import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.richtext.buildRichText import com.zhangke.fread.status.ui.embed.BlogInEmbedding import com.zhangke.fread.status.ui.publish.NameAndAccountInfo import com.zhangke.fread.status.ui.publish.PublishBlogStyle import com.zhangke.fread.status.ui.publish.PublishBlogStyleDefault import com.zhangke.fread.status.ui.threads.ThreadsType import com.zhangke.fread.status.ui.threads.blogBeReplyThreads import com.zhangke.fread.status.ui.threads.blogInReplyingThreads import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource @Composable fun PublishPostScaffold( account: LoggedAccount?, snackBarHostState: SnackbarHostState, content: TextFieldValue, showSwitchAccountIcon: Boolean, showAddAccountIcon: Boolean, publishEnabled: Boolean, publishing: Boolean, replyingBlog: Blog? = null, onContentChanged: (TextFieldValue) -> Unit, onPublishClick: () -> Unit, onBackClick: () -> Unit, onSwitchAccountClick: () -> Unit = {}, onAddAccountClick: () -> Unit = {}, contentWarning: @Composable () -> Unit = {}, postSettingLabel: @Composable () -> Unit, bottomPanel: @Composable () -> Unit, attachment: @Composable (PublishBlogStyle) -> Unit, allowHashtagInHashtag: Boolean = false, ) { val density = LocalDensity.current val style = PublishBlogStyleDefault.defaultStyle() val focusManager = LocalFocusManager.current val keyboardState by keyboardAsState() LaunchedEffect(keyboardState) { if (!keyboardState) { focusManager.clearFocus() } } Scaffold( topBar = { PublishTopBar( publishing = publishing, onBackClick = onBackClick, publishEnabled = publishEnabled, onPublishClick = onPublishClick, ) }, snackbarHost = { SnackbarHost(snackBarHostState) }, bottomBar = { bottomPanel() }, ) { innerPadding -> var contentHeight: Float? by remember { mutableStateOf(null) } var replyingHeight: Float? by remember { mutableStateOf(null) } val scrollState = rememberScrollState() if (replyingBlog != null && replyingHeight != null) { LaunchedEffect(replyingHeight) { delay(300) scrollState.animateScrollBy(replyingHeight!!) } } Column( modifier = Modifier.fillMaxSize() .onSizeChanged { if (contentHeight === null || contentHeight == 0F) { contentHeight = it.height.toFloat() } } .padding(innerPadding) .verticalScroll(scrollState) .let { if (contentHeight != null && replyingHeight != null) { it.heightIn(min = (contentHeight!! + replyingHeight!!).pxToDp(density)) } else { it } }, ) { if (replyingBlog != null) { BlogInEmbedding( modifier = Modifier.fillMaxWidth() .onSizeChanged { if (replyingHeight == null || replyingHeight == 0F) { replyingHeight = it.height.toFloat() } } .blogBeReplyThreads( threadsType = ThreadsType.ANCESTOR, publishBlogStyle = style, ) .padding( start = style.startPadding, end = style.endPadding, ), blog = replyingBlog, style = style.statusStyle, ) } Row( modifier = Modifier.fillMaxWidth() .let { if (replyingBlog != null) { it.blogInReplyingThreads( threadsType = ThreadsType.ANCHOR, infoToTopSpacing = style.topPadding.toPx(), publishBlogStyle = style, ).padding(top = style.topPadding) } else { it } }, ) { AutoSizeImage( url = account?.avatar.orEmpty(), modifier = Modifier.padding(start = 16.dp) .clip(CircleShape) .size(style.statusStyle.infoLineStyle.avatarSize), contentDescription = null, ) Column( modifier = Modifier.weight(1F).padding(horizontal = 8.dp), ) { NameAndAccountInfo( modifier = Modifier.fillMaxWidth(), humanizedName = buildRichText(document = account?.userName.orEmpty()), handle = account?.prettyHandle.orEmpty(), style = style.statusStyle, ) Spacer(modifier = Modifier.height(1.dp)) postSettingLabel() } if (showSwitchAccountIcon) { SimpleIconButton( modifier = Modifier, onClick = onSwitchAccountClick, imageVector = Icons.Default.Group, iconSize = 36.dp, contentDescription = "Switch Account", ) } if (showAddAccountIcon) { Spacer(modifier = Modifier.width(2.dp)) SimpleIconButton( modifier = Modifier, onClick = onAddAccountClick, imageVector = Icons.Default.PersonAdd, iconSize = 36.dp, contentDescription = "Multi Account", ) Spacer(modifier = Modifier.width(8.dp)) } } contentWarning() InputBlogTextField( modifier = Modifier.fillMaxWidth(), textFieldValue = content, onContentChanged = onContentChanged, placeholder = if (replyingBlog != null) { HighlightTextBuildUtil.buildHighlightText( text = stringResource( LocalizedString.sharedPublishReplyInputHint, replyingBlog.author.prettyHandle, ), highLightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.7F) ) } else { buildAnnotatedString { append(stringResource(LocalizedString.sharedPublishBlogTextHint)) } }, allowHashtagInHashtag = allowHashtagInHashtag, ) Spacer(modifier = Modifier.height(8.dp)) attachment(style) } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/PublishSettingLabel.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish import androidx.compose.foundation.background import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp @Composable fun PublishSettingLabel( modifier: Modifier, label: String, icon: ImageVector, tail: (@Composable () -> Unit)? = null, ) { Row( modifier = modifier .background( color = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(6.dp), ).padding(horizontal = 6.dp, vertical = 1.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( modifier = Modifier.size(14.dp), imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( modifier = Modifier.padding(start = 2.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, text = label, style = MaterialTheme.typography.labelSmall, ) if (tail != null) { Spacer(modifier = Modifier.width(2.dp)) tail() } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/PublishTopBar.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.Toolbar import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.stringResource @Composable fun PublishTopBar( publishing: Boolean, publishEnabled: Boolean, onBackClick: () -> Unit, onPublishClick: () -> Unit, ) { Toolbar( title = stringResource(LocalizedString.sharedPublishBlogTitle), onBackClick = onBackClick, actions = { if (publishing) { CircularProgressIndicator( modifier = Modifier .padding(end = 8.dp) .size(24.dp), color = MaterialTheme.colorScheme.primary, ) } else { SimpleIconButton( onClick = onPublishClick, enabled = publishEnabled, tint = MaterialTheme.colorScheme.primary, imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = "Publish", ) } }, ) } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/composable/AvatarsHorizontalStack.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish.composable import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.zhangke.fread.status.ui.BlogAuthorAvatar @Composable fun AvatarsHorizontalStack( modifier: Modifier, avatars: List, style: AvatarStackStyle = AvatarStackStyle.default(), ) { Box( modifier = modifier, ) { avatars.take(3).forEachIndexed { index, url -> AvatarWithBorder( modifier = Modifier.padding(start = 8.dp * index), url = url, style = style, ) } } } @Composable private fun AvatarWithBorder(modifier: Modifier, url: String?, style: AvatarStackStyle) { BlogAuthorAvatar( modifier = modifier.size(style.avatarSize) .border(width = 1.dp, color = style.borderColor, shape = CircleShape), imageUrl = url, ) } data class AvatarStackStyle( val avatarSize: Dp, val borderColor: Color, ) { companion object { @Composable fun default(): AvatarStackStyle { return AvatarStackStyle( avatarSize = 42.dp, borderColor = Color.White, ) } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/composable/BlogMediaAttachment.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish.composable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.zhangke.fread.commonbiz.shared.screen.publish.model.PublishBlogMediaAttachment @Composable fun BlogMediaAttachment( modifier: Modifier, attachment: PublishBlogMediaAttachment, ) { } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/composable/InputBlogTextField.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish.composable import androidx.compose.foundation.shape.GenericShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.TextFieldValue import com.zhangke.framework.utils.transparentIndicatorColors import com.zhangke.fread.status.ui.common.PostStatusTextVisualTransformation import kotlinx.coroutines.delay @Composable fun InputBlogTextField( modifier: Modifier, textFieldValue: TextFieldValue, placeholder: AnnotatedString, onContentChanged: (TextFieldValue) -> Unit, mentionHighlightEnabled: Boolean = true, allowHashtagInHashtag: Boolean = false, ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { delay(500) focusRequester.requestFocus() } TextField( modifier = modifier.focusRequester(focusRequester), shape = GenericShape { _, _ -> }, placeholder = { Text( text = placeholder, style = MaterialTheme.typography.bodyLarge, ) }, minLines = 3, visualTransformation = PostStatusTextVisualTransformation( highLightColor = MaterialTheme.colorScheme.primary, enableMentions = mentionHighlightEnabled, allowHashtagInHashtag = allowHashtagInHashtag, ), value = textFieldValue, colors = TextFieldDefaults.transparentIndicatorColors, textStyle = MaterialTheme.typography.bodyLarge, onValueChange = { onContentChanged(it) }, ) } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/composable/PostInteractionSettingLabel.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish.composable import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.outlined.Group import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Switch import androidx.compose.material3.Text 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.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.noRippleClick import com.zhangke.framework.composable.rememberTransientModalBottomSheetState import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PostInteractionSetting import com.zhangke.fread.status.model.ReplySetting import com.zhangke.fread.status.model.StatusList import com.zhangke.fread.commonbiz.shared.screen.publish.PublishSettingLabel import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class) @Composable fun PostInteractionSettingLabel( modifier: Modifier, setting: PostInteractionSetting, lists: List, onSettingSelected: (PostInteractionSetting) -> Unit, ) { PostInteractionSettingLabel( modifier = modifier, setting = setting, lists = lists, onQuoteChange = { onSettingSelected(setting.copy(allowQuote = it)) }, onSettingSelected = { onSettingSelected(setting.copy(replySetting = it)) }, onSettingOptionsSelected = { option -> val options = setting.replySetting.let { it as? ReplySetting.Combined } ?.options?.toMutableList() ?: mutableListOf() if (option in options) { options.remove(option) } else { options.add(option) } onSettingSelected(setting.copy(replySetting = ReplySetting.Combined(options))) }, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun PostInteractionSettingLabel( modifier: Modifier, setting: PostInteractionSetting, lists: List, onQuoteChange: (Boolean) -> Unit, onSettingSelected: (ReplySetting) -> Unit, onSettingOptionsSelected: (ReplySetting.CombineOption) -> Unit, ) { var showSelector by remember { mutableStateOf(false) } PublishSettingLabel( modifier = modifier.noRippleClick { showSelector = true }, label = setting.label, icon = setting.labelIcon, ) val state = rememberTransientModalBottomSheetState(skipPartiallyExpanded = true) if (showSelector) { ModalBottomSheet( onDismissRequest = { showSelector = false }, sheetState = state, ) { Column( modifier = Modifier.fillMaxWidth() .padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 32.dp) .verticalScroll(rememberScrollState()), ) { Text( text = stringResource(LocalizedString.sharedPublishInteractionDialogTitle), style = MaterialTheme.typography.titleLarge.copy( fontWeight = FontWeight.SemiBold, ), ) Text( text = stringResource(LocalizedString.sharedPublishInteractionDialogSubtitle), style = MaterialTheme.typography.bodyMedium, ) HorizontalDivider(modifier = Modifier.padding(top = 16.dp).fillMaxWidth()) Text( modifier = Modifier.padding(top = 26.dp), text = stringResource(LocalizedString.sharedPublishInteractionDialogQuoteTitle), style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.SemiBold, ), ) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Text( text = stringResource(LocalizedString.sharedPublishInteractionDialogQuoteAllow), style = MaterialTheme.typography.bodySmall, ) Spacer(modifier = Modifier.weight(1F)) Switch( checked = setting.allowQuote, onCheckedChange = onQuoteChange, ) } HorizontalDivider(modifier = Modifier.padding(top = 16.dp).fillMaxWidth()) Text( modifier = Modifier.padding(top = 26.dp), text = stringResource(LocalizedString.sharedPublishInteractionDialogReplyTitle), style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.SemiBold, ), ) Text( modifier = Modifier.padding(top = 16.dp), text = stringResource(LocalizedString.sharedPublishInteractionDialogReplySubtitle), style = MaterialTheme.typography.bodySmall, ) Row( modifier = Modifier.fillMaxWidth().padding(top = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { InteractionOption( modifier = Modifier.weight(1F) .noRippleClick { onSettingSelected(ReplySetting.Everybody) }, text = stringResource(LocalizedString.sharedPublishInteractionDialogReplyAll), selected = setting.replySetting is ReplySetting.Everybody, ) Spacer(modifier = Modifier.width(16.dp)) InteractionOption( modifier = Modifier.weight(1F) .noRippleClick { onSettingSelected(ReplySetting.Nobody) }, text = stringResource(LocalizedString.sharedPublishInteractionDialogReplyNobody), selected = setting.replySetting is ReplySetting.Nobody, ) } if (setting.replySetting !is ReplySetting.Nobody) { Text( modifier = Modifier.padding(top = 26.dp), text = stringResource(LocalizedString.sharedPublishInteractionDialogReplyCombineTitle), style = MaterialTheme.typography.bodySmall, ) InteractionOption( modifier = Modifier.padding(top = 16.dp).fillMaxWidth() .noRippleClick { onSettingOptionsSelected(ReplySetting.CombineOption.Mentioned) }, text = stringResource(LocalizedString.sharedPublishInteractionDialogMentioned), selected = setting.replySetting.combinedMentions, ) InteractionOption( modifier = Modifier.padding(top = 16.dp).fillMaxWidth() .noRippleClick { onSettingOptionsSelected(ReplySetting.CombineOption.Following) }, text = stringResource(LocalizedString.sharedPublishInteractionDialogFollowing), selected = setting.replySetting.combinedFollowing, ) InteractionOption( modifier = Modifier.padding(top = 16.dp).fillMaxWidth() .noRippleClick { onSettingOptionsSelected(ReplySetting.CombineOption.Followers) }, text = stringResource(LocalizedString.sharedPublishInteractionDialogFollower), selected = setting.replySetting.combinedFollowers, ) for (listView in lists) { val selected = if (setting.replySetting is ReplySetting.Combined) { (setting.replySetting as ReplySetting.Combined).options .filterIsInstance() .any { it.listView.cid == listView.cid } } else { false } InteractionOption( modifier = Modifier.padding(top = 16.dp).fillMaxWidth() .noRippleClick { onSettingOptionsSelected( ReplySetting.CombineOption.UserInList(listView) ) }, text = listView.name, selected = selected, ) } } } } } } @Composable fun InteractionOption( modifier: Modifier, text: String, selected: Boolean, ) { val backgroundColor = if (selected) { MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3F) } else { MaterialTheme.colorScheme.surfaceContainer } Row( modifier = modifier.background( color = backgroundColor, shape = RoundedCornerShape(6.dp), ).padding(vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { val fontStyle = if (selected) { MaterialTheme.typography.bodyMedium.copy( fontWeight = FontWeight.SemiBold, ) } else { MaterialTheme.typography.bodyMedium } Text( modifier = Modifier.padding(start = 16.dp), text = text, style = fontStyle, ) if (selected) { Spacer(modifier = Modifier.weight(1F)) Icon( imageVector = Icons.Default.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(end = 16.dp), ) } } } internal val PostInteractionSetting.label: String @Composable get() { return if (this.replySetting is ReplySetting.Everybody) { stringResource(LocalizedString.sharedPublishInteractionNoLimit) } else { stringResource(LocalizedString.sharedPublishInteractionLimited) } } internal val PostInteractionSetting.labelIcon: ImageVector @Composable get() { return if (this.replySetting is ReplySetting.Everybody) { Icons.Default.Public } else { Icons.Outlined.Group } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/composable/PostStatusVisibilityUi.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish.composable import androidx.compose.foundation.layout.Box import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Public import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Text 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.Modifier import com.zhangke.framework.composable.PopupMenu import com.zhangke.framework.composable.noRippleClick import com.zhangke.fread.commonbiz.shared.screen.publish.PublishSettingLabel import com.zhangke.fread.commonbiz.shared.screen.publish.multi.describeStringId import com.zhangke.fread.status.model.StatusVisibility import org.jetbrains.compose.resources.stringResource @Composable fun PostStatusVisibilityUi( modifier: Modifier, visibility: StatusVisibility, changeable: Boolean, onVisibilitySelect: (StatusVisibility) -> Unit, ) { var showSelector by remember { mutableStateOf(false) } Box(modifier = modifier) { PublishSettingLabel( modifier = modifier.noRippleClick(enabled = changeable) { showSelector = true }, label = stringResource(visibility.describeStringId), icon = Icons.Default.Public, ) PopupMenu( expanded = showSelector, onDismissRequest = { showSelector = false }, ) { StatusVisibility.entries.forEach { DropdownMenuItem( text = { Text(text = stringResource(it.describeStringId)) }, onClick = { showSelector = false onVisibilitySelect(it) }, ) } } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/composable/PostStatusWarning.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish.composable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.zhangke.framework.architect.theme.inverseOnSurfaceDark import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.ui.drawSpoilerBackground import org.jetbrains.compose.resources.stringResource @Composable fun PostStatusWarning( modifier: Modifier, warning: TextFieldValue, onValueChanged: (TextFieldValue) -> Unit, ) { Box( modifier = modifier.drawSpoilerBackground() ) { TextField( modifier = Modifier .padding(top = 8.dp) .fillMaxSize(), value = warning, onValueChange = onValueChanged, placeholder = { Text( text = stringResource(LocalizedString.postStatusContentWarning), style = MaterialTheme.typography.bodyMedium, color = inverseOnSurfaceDark, ) }, colors = TextFieldDefaults.colors( focusedTextColor = inverseOnSurfaceDark, unfocusedTextColor = inverseOnSurfaceDark, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, disabledIndicatorColor = Color.Transparent, errorIndicatorColor = Color.Transparent, disabledContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent, errorContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, ), textStyle = MaterialTheme.typography.bodyMedium, ) } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/model/PublishBlogMediaAttachment.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish.model sealed interface PublishBlogMediaAttachment { data class Image(val files: List) : PublishBlogMediaAttachment data class Video(val file: PublishBlogMediaAttachmentFile) : PublishBlogMediaAttachment } data class PublishBlogMediaAttachmentFile( val uri: String, val description: String?, ) ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/multi/MultiAccountPublishingScreen.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish.multi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState 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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.architect.json.globalJson import com.zhangke.framework.composable.BackHandler import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.FreadDialog import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.popIfNotRoot import com.zhangke.framework.toast.toast import com.zhangke.framework.utils.PlatformUri import com.zhangke.framework.utils.languageCode import com.zhangke.fread.commonbiz.shared.screen.publish.PublishPostFeaturesPanel import com.zhangke.fread.commonbiz.shared.screen.publish.PublishPostMedia import com.zhangke.fread.commonbiz.shared.screen.publish.PublishPostMediaAttachment import com.zhangke.fread.commonbiz.shared.screen.publish.PublishTopBar import com.zhangke.fread.commonbiz.shared.screen.publish.SensitiveIconButton import com.zhangke.fread.commonbiz.shared.screen.publish.bottomPaddingAsBottomBar import com.zhangke.fread.commonbiz.shared.screen.publish.composable.InputBlogTextField import com.zhangke.fread.commonbiz.shared.screen.publish.composable.PostInteractionSettingLabel import com.zhangke.fread.commonbiz.shared.screen.publish.composable.PostStatusVisibilityUi import com.zhangke.fread.commonbiz.shared.screen.publish.composable.PostStatusWarning import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.PostInteractionSetting import com.zhangke.fread.status.model.StatusVisibility import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable data class MultiAccountPublishingScreenKey(val userUrisJson: String) : NavKey { companion object { fun create(accounts: List): MultiAccountPublishingScreenKey { return MultiAccountPublishingScreenKey( userUrisJson = globalJson.encodeToString(accounts.map { it.uri.toString() }), ) } } } @OptIn(ExperimentalComposeUiApi::class) @Composable fun MultiAccountPublishingScreen( viewModel: MultiAccountPublishingViewModel, ) { val backStack = LocalNavBackStack.currentOrThrow val snackBarHostState = rememberSnackbarHostState() val uiState by viewModel.uiState.collectAsState() var showExitDialog by remember { mutableStateOf(false) } fun onBack() { if (uiState.hasInputtedData) { showExitDialog = true return } backStack.popIfNotRoot() } MultiAccountPublishingContent( uiState = uiState, snackBarHostState = snackBarHostState, onBackClick = backStack::popIfNotRoot, onPublishClick = viewModel::onPublishClick, onMediaSelected = viewModel::onMediaSelected, onLanguageSelected = viewModel::onLanguageSelected, onRemoveAccountClick = viewModel::onRemoveAccountClick, onContentChanged = viewModel::onContentChanged, onSensitiveClick = viewModel::onSensitiveClick, onWarningContentChanged = viewModel::onWarningContentChanged, onMediaAltChanged = viewModel::onMediaAltChanged, onDeleteMediaClick = viewModel::onDeleteMediaClick, onVisibilitySelect = viewModel::onVisibilitySelect, onSettingSelected = viewModel::onSettingSelected, onAddAccountClick = viewModel::onAddAccount, ) val successMessage = stringResource(LocalizedString.postStatusSuccess) ConsumeFlow(viewModel.publishSuccessFlow) { toast(successMessage) backStack.popIfNotRoot() } BackHandler(true) { onBack() } if (showExitDialog) { FreadDialog( onDismissRequest = { showExitDialog = false }, content = { Text(text = stringResource(LocalizedString.postStatusExitDialogContent)) }, onNegativeClick = { showExitDialog = false }, onPositiveClick = { showExitDialog = false backStack.popIfNotRoot() }, ) } ConsumeSnackbarFlow(snackBarHostState, viewModel.snackMessage) } @Composable private fun MultiAccountPublishingContent( uiState: MultiAccountPublishingUiState, snackBarHostState: SnackbarHostState, onBackClick: () -> Unit, onPublishClick: () -> Unit, onMediaSelected: (List) -> Unit, onLanguageSelected: (String) -> Unit, onRemoveAccountClick: (LoggedAccount) -> Unit, onContentChanged: (TextFieldValue) -> Unit, onSensitiveClick: () -> Unit, onWarningContentChanged: (TextFieldValue) -> Unit, onMediaAltChanged: (PublishPostMedia, String) -> Unit, onDeleteMediaClick: (PublishPostMedia) -> Unit, onVisibilitySelect: (StatusVisibility) -> Unit, onSettingSelected: (PostInteractionSetting) -> Unit, onAddAccountClick: (MultiPublishingAccountWithRules) -> Unit, ) { Scaffold( topBar = { PublishTopBar( publishing = uiState.publishing, onBackClick = onBackClick, publishEnabled = uiState.publishEnabled, onPublishClick = onPublishClick, ) }, snackbarHost = { SnackbarHost(snackBarHostState) }, bottomBar = { PublishPostFeaturesPanel( modifier = Modifier.fillMaxWidth().bottomPaddingAsBottomBar(), contentLength = uiState.content.text.length, maxContentLimit = uiState.globalRules.maxCharacters, mediaAvailableCount = uiState.mediaAvailableCount, onMediaSelected = onMediaSelected, containerColor = MaterialTheme.colorScheme.background, selectedLanguages = listOf(uiState.selectedLanguage.languageCode), maxLanguageCount = uiState.globalRules.maxLanguageCount, onLanguageSelected = { it.firstOrNull()?.let { lan -> onLanguageSelected(lan) } }, actions = { SensitiveIconButton(onSensitiveClick = onSensitiveClick) }, ) }, ) { innerPadding -> Column( modifier = Modifier.fillMaxSize() .padding(innerPadding) .verticalScroll(rememberScrollState()), ) { PublishingAccounts( modifier = Modifier.fillMaxWidth(), uiState = uiState, onRemoveAccountClick = onRemoveAccountClick, onAddAccountClick = onAddAccountClick, ) if (uiState.showInteractionSetting || uiState.showPostVisibilitySetting) { Row( modifier = Modifier.fillMaxWidth() .padding(start = 16.dp, top = 16.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { if (uiState.showPostVisibilitySetting) { PostStatusVisibilityUi( modifier = Modifier, changeable = true, visibility = uiState.postVisibility, onVisibilitySelect = onVisibilitySelect, ) Spacer(modifier = Modifier.width(8.dp)) } if (uiState.showInteractionSetting) { PostInteractionSettingLabel( modifier = Modifier, setting = uiState.interactionSetting, lists = emptyList(), onSettingSelected = onSettingSelected, ) } } } if (uiState.sensitive) { PostStatusWarning( modifier = Modifier.fillMaxWidth() .padding(start = 16.dp, top = 16.dp, end = 16.dp), warning = uiState.warningContent, onValueChanged = onWarningContentChanged, ) } InputBlogTextField( modifier = Modifier.fillMaxWidth(), textFieldValue = uiState.content, onContentChanged = onContentChanged, mentionHighlightEnabled = false, placeholder = buildAnnotatedString { append(stringResource(LocalizedString.sharedPublishBlogTextHint)) }, ) PublishPostMediaAttachment( modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp) .fillMaxWidth(), medias = uiState.medias, mediaAltMaxCharacters = uiState.globalRules.mediaAltMaxCharacters, onAltChanged = onMediaAltChanged, onDeleteClick = onDeleteMediaClick, ) } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/multi/MultiAccountPublishingUiState.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish.multi import androidx.compose.ui.text.input.TextFieldValue import com.zhangke.framework.utils.ContentProviderFile import com.zhangke.framework.utils.Locale import com.zhangke.framework.utils.getDefaultLocale import com.zhangke.fread.commonbiz.shared.screen.publish.PublishPostMedia import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.PostInteractionSetting import com.zhangke.fread.status.model.PublishBlogRules import com.zhangke.fread.status.model.StatusVisibility import com.zhangke.fread.status.model.isActivityPub import com.zhangke.fread.status.model.isBluesky import org.jetbrains.compose.resources.StringResource data class MultiAccountPublishingUiState( val addedAccounts: List, val allAccounts: List, val publishing: Boolean, val content: TextFieldValue, val globalRules: PublishBlogRules, val medias: List, val selectedLanguage: Locale, val postVisibility: StatusVisibility, val interactionSetting: PostInteractionSetting, val sensitive: Boolean, val warningContent: TextFieldValue, ) { val publishEnabled: Boolean get() { if (publishing) return false if (content.text.isEmpty() && medias.isEmpty()) return false if (addedAccounts.isEmpty()) return false return true } val mediaAvailableCount: Int get() = globalRules.maxMediaCount - medias.size val showPostVisibilitySetting: Boolean get() = allAccounts.any { it.account.platform.protocol.isActivityPub } val showInteractionSetting: Boolean get() = allAccounts.any { it.account.platform.protocol.isBluesky } val hasInputtedData: Boolean get() = content.text.isNotEmpty() || medias.isNotEmpty() || warningContent.text.isNotEmpty() companion object { fun default(): MultiAccountPublishingUiState { return MultiAccountPublishingUiState( addedAccounts = emptyList(), allAccounts = emptyList(), publishing = false, content = TextFieldValue(""), globalRules = defaultRules(), medias = emptyList(), selectedLanguage = getDefaultLocale(), postVisibility = StatusVisibility.PUBLIC, interactionSetting = PostInteractionSetting.default(), sensitive = false, warningContent = TextFieldValue(""), ) } fun defaultRules(): PublishBlogRules { return PublishBlogRules( maxCharacters = 120, maxMediaCount = 4, maxPollOptions = 0, supportPoll = false, supportSpoiler = false, maxLanguageCount = 1, mediaAltMaxCharacters = 1500, ) } } } data class MultiPublishingAccountUiState( val account: LoggedAccount, val rules: PublishBlogRules, ) data class MultiPublishingAccountWithRules( val account: LoggedAccount, val rules: PublishBlogRules?, ) val StatusVisibility.describeStringId: StringResource get() = when (this) { StatusVisibility.PUBLIC -> LocalizedString.postStatusScopePublic StatusVisibility.UNLISTED -> LocalizedString.postStatusScopeUnlisted StatusVisibility.PRIVATE -> LocalizedString.postStatusScopeFollowerOnly StatusVisibility.DIRECT -> LocalizedString.postStatusScopeMentionedOnly } data class PublishPostMediaAttachmentFile( val file: ContentProviderFile, override val isVideo: Boolean, override val alt: String?, ) : PublishPostMedia { override val uri: String get() = file.uri.toString() } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/multi/MultiAccountPublishingViewModel.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish.multi import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.emitInViewModel import com.zhangke.framework.composable.textOf import com.zhangke.framework.ktx.ifNullOrEmpty import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.utils.PlatformUri import com.zhangke.framework.utils.initLocale import com.zhangke.framework.utils.languageCode import com.zhangke.fread.common.utils.PlatformUriHelper import com.zhangke.fread.commonbiz.shared.repo.SelectedAccountPublishingRepo import com.zhangke.fread.commonbiz.shared.screen.publish.PublishPostMedia import com.zhangke.fread.commonbiz.shared.usecase.PublishPostOnMultiAccountUseCase import com.zhangke.fread.commonbiz.shared.usecase.PublishingPartFailed import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.PostInteractionSetting import com.zhangke.fread.status.model.PublishBlogRules import com.zhangke.fread.status.model.StatusVisibility import com.zhangke.fread.status.publish.PublishingMedia import com.zhangke.fread.status.publish.PublishingPost import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update class MultiAccountPublishingViewModel ( private val statusProvider: StatusProvider, private val platformUriHelper: PlatformUriHelper, private val publishPostOnMultiAccount: PublishPostOnMultiAccountUseCase, private val selectedAccountPublishingRepo: SelectedAccountPublishingRepo, private val defaultAddAccountList: List, ) : ViewModel() { private val _uiState = MutableStateFlow(MultiAccountPublishingUiState.default()) val uiState = _uiState.asStateFlow() private val _snackMessage = MutableSharedFlow() val snackMessage: SharedFlow get() = _snackMessage private val _publishSuccessFlow = MutableSharedFlow() val publishSuccessFlow: SharedFlow get() = _publishSuccessFlow init { launchInViewModel { val allAccounts = statusProvider.accountManager.getAllLoggedAccount() val addedAccounts = getInitialAccount(allAccounts) _uiState.update { it.copy( addedAccounts = addedAccounts.map { it.toDefaultUiState() }, allAccounts = allAccounts.map { MultiPublishingAccountWithRules(it, null) }, ) } val addedAccountUiState = addedAccounts.map { account -> val rules = loadRules(account) ?: MultiAccountPublishingUiState.defaultRules() MultiPublishingAccountUiState(account, rules) } _uiState.update { state -> state.copy( addedAccounts = addedAccountUiState, allAccounts = state.allAccounts.map { (account, _) -> val rules = addedAccountUiState.firstOrNull { it.account.uri == account.uri }?.rules MultiPublishingAccountWithRules(account, rules) }, ) } updateGlobalRules() } } private suspend fun getInitialAccount(allLoggedAccounts: List): List { val pendingAddAccounts = selectedAccountPublishingRepo.getAll() + defaultAddAccountList return allLoggedAccounts.filter { account -> pendingAddAccounts.contains(account.uri.toString()) } } fun onAddAccount(account: MultiPublishingAccountWithRules) { if (uiState.value.addedAccounts.any { it.account.uri == account.account.uri }) return _uiState.update { it.copy(addedAccounts = it.addedAccounts + account.toDefaultUiState()) } if (account.rules == null) { loadRuleForAccount(account.account) } updateGlobalRules() updateLocalAccounts() } fun onRemoveAccountClick(account: LoggedAccount) { if (uiState.value.addedAccounts.size <= 1) return _uiState.update { it.copy(addedAccounts = it.addedAccounts.filter { it.account.uri != account.uri }) } updateGlobalRules() updateLocalAccounts() } private fun updateLocalAccounts() { launchInViewModel { val addedAccounts = _uiState.value.addedAccounts selectedAccountPublishingRepo.replace(addedAccounts.map { it.account.uri.toString() }) } } fun onContentChanged(content: TextFieldValue) { _uiState.update { it.copy(content = content) } } fun onSensitiveClick() { _uiState.update { it.copy(sensitive = !it.sensitive) } } fun onWarningContentChanged(content: TextFieldValue) { _uiState.update { it.copy(warningContent = content) } } fun onMediaAltChanged(media: PublishPostMedia, alt: String) { _uiState.update { it.copy( medias = it.medias.map { m -> if (m.uri == media.uri) m.copy(alt = alt) else m }, ) } } fun onDeleteMediaClick(media: PublishPostMedia) { _uiState.update { it.copy(medias = it.medias.filter { m -> m.uri != media.uri }) } } fun onVisibilitySelect(visibility: StatusVisibility) { _uiState.update { it.copy(postVisibility = visibility) } } fun onSettingSelected(setting: PostInteractionSetting) { _uiState.update { it.copy(interactionSetting = setting) } } private fun loadRuleForAccount(account: LoggedAccount) { launchInViewModel { val rules = loadRules(account) ?: return@launchInViewModel _uiState.update { state -> state.copy( allAccounts = state.allAccounts.map { if (it.account.uri == account.uri) { it.copy(rules = rules) } else { it } }, addedAccounts = state.addedAccounts.map { if (it.account.uri == account.uri) { it.copy(rules = rules) } else { it } }, ) } } } fun onPublishClick() { val uiState = _uiState.value if (uiState.medias.isEmpty() && uiState.content.text.isEmpty()) { _snackMessage.emitInViewModel(textOf(LocalizedString.postStatusContentIsEmpty)) return } launchInViewModel { _uiState.update { it.copy(publishing = true) } publishPostOnMultiAccount( accounts = uiState.addedAccounts.map { it.account }, publishingPost = uiState.obtainPost(), ).onFailure { t -> _uiState.update { it.copy(publishing = false) } if (t is PublishingPartFailed) { _snackMessage.emit( textOf(LocalizedString.postStatusPartFailed, t.message.orEmpty()) ) _uiState.update { state -> state.copy( addedAccounts = state.addedAccounts.filter { !t.successAccount.contains(it.account.uri.toString()) }, ) } } else { val errorMessage = textOf( LocalizedString.postStatusFailed, t.message.ifNullOrEmpty { "unknown error" }.take(180), ) _snackMessage.emit(errorMessage) } }.onSuccess { _uiState.update { it.copy(publishing = false) } _publishSuccessFlow.emit(Unit) } } } private fun MultiAccountPublishingUiState.obtainPost(): PublishingPost { return PublishingPost( content = this.content.text, visibility = this.postVisibility, interactionSetting = this.interactionSetting, sensitive = this.sensitive, warningText = this.warningContent.text, languageCode = this.selectedLanguage.languageCode, medias = this.medias.map { it.convert() } ) } private fun PublishPostMediaAttachmentFile.convert(): PublishingMedia { return PublishingMedia( file = this.file, alt = this.alt.orEmpty(), isVideo = isVideo, ) } fun onMediaSelected(medias: List) { if (medias.isEmpty()) return launchInViewModel { val fileList = medias.map { async { platformUriHelper.read(it) } } .awaitAll().filterNotNull() val newMedias = if (fileList.first().isVideo) { PublishPostMediaAttachmentFile( file = fileList.first(), isVideo = true, alt = null, ).let { listOf(it) } } else { uiState.value.medias + fileList.map { PublishPostMediaAttachmentFile( file = it, isVideo = false, alt = null, ) } } _uiState.update { it.copy(medias = newMedias) } } } fun onLanguageSelected(lan: String) { _uiState.update { it.copy(selectedLanguage = initLocale(lan)) } } private fun updateGlobalRules() { val addedAccount = uiState.value.addedAccounts val maxCharacters = addedAccount.minOf { it.rules.maxCharacters } val maxMediaCount = addedAccount.minOf { it.rules.maxMediaCount } _uiState.update { it.copy( globalRules = it.globalRules.copy( maxCharacters = maxCharacters, maxMediaCount = maxMediaCount, ) ) } } private fun LoggedAccount.toDefaultUiState(): MultiPublishingAccountUiState { return MultiPublishingAccountUiState( account = this, rules = MultiAccountPublishingUiState.defaultRules(), ) } private fun MultiPublishingAccountWithRules.toDefaultUiState(): MultiPublishingAccountUiState { return MultiPublishingAccountUiState( account = this.account, rules = this.rules ?: MultiAccountPublishingUiState.defaultRules(), ) } private suspend fun loadRules(account: LoggedAccount): PublishBlogRules? { return statusProvider.publishManager.getPublishBlogRules(account).getOrNull() } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/publish/multi/PublishingAccounts.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.publish.multi import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.horizontalScroll 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.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.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.rounded.Add import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.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.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.noRippleClick import com.zhangke.framework.utils.getDisplayName import com.zhangke.framework.utils.initLocale import com.zhangke.framework.utils.languageCode import com.zhangke.fread.common.resources.logo import com.zhangke.fread.commonbiz.shared.screen.publish.PublishSettingLabel import com.zhangke.fread.commonbiz.shared.screen.publish.composable.label import com.zhangke.fread.commonbiz.shared.screen.publish.composable.labelIcon import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.isActivityPub import com.zhangke.fread.status.model.isBluesky import com.zhangke.fread.status.ui.BlogAuthorAvatar import com.zhangke.fread.status.ui.common.RemainingTextStatus import com.zhangke.fread.status.ui.common.SelectAccountDialog import com.zhangke.fread.status.ui.richtext.FreadRichText import org.jetbrains.compose.resources.stringResource @Composable fun PublishingAccounts( modifier: Modifier, uiState: MultiAccountPublishingUiState, onRemoveAccountClick: (LoggedAccount) -> Unit, onAddAccountClick: (MultiPublishingAccountWithRules) -> Unit, ) { var showSelectAccountPopup by remember { mutableStateOf(false) } Box(modifier = modifier) { Row( modifier = Modifier.fillMaxWidth() .padding(vertical = 8.dp) .horizontalScroll(rememberScrollState()) .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { for (index in uiState.addedAccounts.indices) { val account = uiState.addedAccounts[index] Box( modifier = Modifier.size(46.dp) .noRippleClick { onRemoveAccountClick(account.account) }, ) { BlogAuthorAvatar( modifier = Modifier.fillMaxSize().padding(2.dp), imageUrl = account.account.avatar, ) Image( imageVector = account.account.platform.protocol.logo, modifier = Modifier.align(Alignment.BottomEnd).size(16.dp), contentDescription = null, ) } if (index < uiState.addedAccounts.lastIndex) { Spacer(modifier = Modifier.width(16.dp)) } } SimpleIconButton( modifier = Modifier.padding(start = 8.dp), iconModifier = Modifier.border( width = 1.dp, shape = CircleShape, color = MaterialTheme.colorScheme.tertiary, ), imageVector = Icons.Rounded.Add, tint = MaterialTheme.colorScheme.tertiary, onClick = { showSelectAccountPopup = true }, contentDescription = "Add Account", ) } HorizontalDivider(modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter)) } if (showSelectAccountPopup) { SelectAccountDialog( accountList = uiState.allAccounts.map { it.account }, selectedAccounts = uiState.addedAccounts.map { it.account }, onDismissRequest = { showSelectAccountPopup = false }, onAccountClicked = { account -> showSelectAccountPopup = false val selected = uiState.addedAccounts.any { it.account.uri == account.uri } if (selected) { onRemoveAccountClick( uiState.addedAccounts.first { it.account.uri == account.uri }.account ) } else { onAddAccountClick(uiState.allAccounts.first { it.account.uri == account.uri }) } }, ) } } @Composable fun PublishingAccountsOld( modifier: Modifier, uiState: MultiAccountPublishingUiState, onRemoveAccountClick: (LoggedAccount) -> Unit, onAddAccountClick: (MultiPublishingAccountWithRules) -> Unit, ) { var showSelectAccountPopup by remember { mutableStateOf(false) } Box( modifier = modifier, ) { SimpleIconButton( modifier = Modifier.padding(start = 8.dp, top = 8.dp), iconModifier = Modifier.border( width = 1.dp, shape = CircleShape, color = MaterialTheme.colorScheme.tertiary, ), imageVector = Icons.Rounded.Add, tint = MaterialTheme.colorScheme.tertiary, onClick = { showSelectAccountPopup = true }, contentDescription = "Add Account", ) Column( modifier = Modifier.padding(start = 48.dp), ) { for (index in uiState.addedAccounts.indices) { val account = uiState.addedAccounts[index] Box( modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp) ) { AccountItem( modifier = Modifier.fillMaxWidth(), uiState = uiState, accountUiState = account, onRemoveAccountClick = onRemoveAccountClick, ) } if (index < uiState.addedAccounts.lastIndex) { Spacer(modifier = Modifier.padding(top = 8.dp)) } } } } if (showSelectAccountPopup) { SelectAccountDialog( accountList = uiState.allAccounts.map { it.account }, selectedAccounts = uiState.addedAccounts.map { it.account }, onDismissRequest = { showSelectAccountPopup = false }, onAccountClicked = { account -> showSelectAccountPopup = false onAddAccountClick(uiState.allAccounts.first { it.account.uri == account.uri }) }, ) } } @Composable private fun AccountItem( modifier: Modifier, uiState: MultiAccountPublishingUiState, accountUiState: MultiPublishingAccountUiState, onRemoveAccountClick: (LoggedAccount) -> Unit, ) { val account = accountUiState.account Card( modifier = modifier, shape = RoundedCornerShape(16.dp), ) { Column( modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) ) { Row( modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp), ) { Box( modifier = Modifier.size(42.dp).align(Alignment.CenterVertically), ) { BlogAuthorAvatar( modifier = Modifier.fillMaxSize(), imageUrl = account.avatar, ) Image( imageVector = account.platform.protocol.logo, modifier = Modifier.align(Alignment.BottomEnd).size(16.dp), contentDescription = null, ) } Column( modifier = Modifier.weight(1F) .padding(start = 8.dp) .align(Alignment.CenterVertically) ) { FreadRichText( modifier = Modifier.fillMaxWidth(), maxLines = 1, overflow = TextOverflow.Ellipsis, content = account.userName, emojis = account.emojis, onUrlClick = {}, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, ) Text( modifier = Modifier.fillMaxWidth().padding(top = 2.dp), text = account.prettyHandle, maxLines = 1, style = MaterialTheme.typography.labelMedium, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } IconButton( onClick = { onRemoveAccountClick(account) }, ) { Icon( imageVector = Icons.Default.Delete, contentDescription = "Remove", ) } } Row( modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 8.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { if (account.platform.protocol.isBluesky) { PublishSettingLabel( modifier = Modifier, label = uiState.interactionSetting.label, icon = uiState.interactionSetting.labelIcon, ) } else if (account.platform.protocol.isActivityPub) { PublishSettingLabel( modifier = Modifier, label = stringResource(uiState.postVisibility.describeStringId), icon = Icons.Default.Public, ) } Spacer(modifier = Modifier.weight(1F)) val lan = uiState.selectedLanguage Text( text = remember(lan) { initLocale(lan.languageCode).getDisplayName(lan) }, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) RemainingTextStatus( modifier = Modifier.padding(start = 8.dp), maxCount = accountUiState.rules.maxCharacters, contentLength = uiState.content.text.length, ) } } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/search/AbstractSearchStatusScreen.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.search 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.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.ConsumeOpenScreenFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.keyboardAsState import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.loadable.lazycolumn.ObserveLoadMore import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.utils.transparentIndicatorAndContainerColors import com.zhangke.fread.commonbiz.shared.composable.FeedsStatusNode import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.ui.ComposedStatusInteraction import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource @Composable fun AbstractSearchStatusScreen(viewModel: AbstractSearchStatusViewModel) { val backStack = LocalNavBackStack.currentOrThrow val snackbarHostState = rememberSnackbarHostState() val uiState by viewModel.uiState.collectAsState() SearchStatusContent( uiState = uiState, snackbarHostState = snackbarHostState, composedStatusInteraction = viewModel.composedStatusInteraction, onBackClick = backStack::removeLastOrNull, onQueryChanged = viewModel::onQueryChange, onSearchClick = viewModel::onSearchClick, onLoadMore = viewModel::onLoadMore, ) ConsumeSnackbarFlow(snackbarHostState, viewModel.errorMessageFlow) ConsumeOpenScreenFlow(viewModel.openScreenFlow) } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SearchStatusContent( uiState: SearchStatusUiState, snackbarHostState: SnackbarHostState, composedStatusInteraction: ComposedStatusInteraction, onBackClick: () -> Unit, onQueryChanged: (String) -> Unit, onSearchClick: () -> Unit, onLoadMore: () -> Unit, ) { val focusManager = LocalFocusManager.current val keyboardState by keyboardAsState() LaunchedEffect(keyboardState) { if (!keyboardState) { focusManager.clearFocus() } } val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { delay(200) focusRequester.requestFocus() } Scaffold( modifier = Modifier.fillMaxSize(), topBar = { TopAppBar( navigationIcon = { Toolbar.BackButton(onBackClick = onBackClick) }, title = { TextField( modifier = Modifier.fillMaxWidth() .focusRequester(focusRequester), value = uiState.query, onValueChange = onQueryChanged, placeholder = { Text( text = stringResource(LocalizedString.statusUiSearchAccountStatusHint), ) }, keyboardActions = KeyboardActions( onSearch = { focusManager.clearFocus() onSearchClick() } ), keyboardOptions = KeyboardOptions.Default.copy( imeAction = ImeAction.Search ), trailingIcon = { if (uiState.searching) { CircularProgressIndicator( modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary, ) } }, colors = TextFieldDefaults.transparentIndicatorAndContainerColors, maxLines = 1, ) }, ) }, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { innerPadding -> val listState = rememberLazyListState() ObserveLoadMore( lazyListState = listState, onLoadMore = onLoadMore, ) LazyColumn( modifier = Modifier .fillMaxSize() .padding(innerPadding), state = listState, ) { itemsIndexed(uiState.result) { index, status -> FeedsStatusNode( status = status, indexInList = index, composedStatusInteraction = composedStatusInteraction, ) } } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/search/AbstractSearchStatusViewModel.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.search import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.composable.toTextStringOrNull import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.utils.LoadState import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.feeds.IInteractiveHandler import com.zhangke.fread.commonbiz.shared.feeds.InteractiveHandleResult import com.zhangke.fread.commonbiz.shared.feeds.InteractiveHandler import com.zhangke.fread.commonbiz.shared.feeds.handle import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.StatusUiState import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update abstract class AbstractSearchStatusViewModel( private val statusProvider: StatusProvider, private val statusUiStateAdapter: StatusUiStateAdapter, statusUpdater: StatusUpdater, refactorToNewStatus: RefactorToNewStatusUseCase, ) : ViewModel(), IInteractiveHandler by InteractiveHandler( statusProvider = statusProvider, statusUpdater = statusUpdater, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, ) { private val _uiState = MutableStateFlow(SearchStatusUiState.default()) val uiState = _uiState.asStateFlow() private var searchJob: Job? = null private var loadMoreJob: Job? = null init { initInteractiveHandler( coroutineScope = viewModelScope, onInteractiveHandleResult = { it.handleResult() }, ) } fun onQueryChange(query: String) { _uiState.update { it.copy(query = query) } if (searchJob?.isActive == true) searchJob?.cancel() doSearch() } fun onSearchClick() { doSearch() } fun onLoadMore() { if (_uiState.value.searching) return if (_uiState.value.result.isEmpty()) return if (searchJob?.isActive == true) return if (loadMoreJob?.isActive == true) return loadMoreJob = launchInViewModel { _uiState.update { it.copy(loadMoreState = LoadState.Loading) } performSearch( query = _uiState.value.query, loadMore = true, ).onSuccess { statuses -> _uiState.update { it.copy( loadMoreState = LoadState.Idle, result = it.result + statuses, ) } }.onFailure { t -> _uiState.update { it.copy(loadMoreState = LoadState.Failed(t.toTextStringOrNull())) } } } } private fun doSearch() { val query = _uiState.value.query if (query.isEmpty() || query.isBlank()) { _uiState.update { it.copy(result = emptyList()) } return } searchJob = launchInViewModel { _uiState.update { it.copy(searching = true) } performSearch(query = query, loadMore = false) .onSuccess { statuses -> _uiState.update { it.copy(searching = false, result = statuses) } }.onFailure { t -> _uiState.update { it.copy(searching = false) } mutableErrorMessageFlow.emitTextMessageFromThrowable(t) } } } abstract suspend fun performSearch( query: String, loadMore: Boolean, ): Result> private suspend fun InteractiveHandleResult.handleResult() { handle( uiStatusUpdater = { newUiState -> _uiState.update { currentUiState -> currentUiState.copy( result = currentUiState.result.map { if (it.status.intrinsicBlog.id == newUiState.status.intrinsicBlog.id) { newUiState } else { it } } ) } }, deleteStatus = { statusId -> _uiState.update { state -> state.copy( result = state.result.filter { it.status.id != statusId } ) } }, followStateUpdater = { _, _ -> }, ) } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/search/SearchStatusUiState.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.search import com.zhangke.framework.utils.LoadState import com.zhangke.fread.status.model.StatusUiState data class SearchStatusUiState( val query: String, val searching: Boolean, val loadMoreState: LoadState, val result: List, ) { companion object { fun default() = SearchStatusUiState( query = "", searching = false, loadMoreState = LoadState.Idle, result = emptyList(), ) } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/status/account/SelectAccountOpenStatusScreen.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.status.account import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text 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.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.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.rememberTransientModalBottomSheetState import com.zhangke.fread.common.utils.GlobalScreenNavigation import com.zhangke.fread.commonbiz.shared.screen.status.context.StatusContextScreenNavKey import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.ui.user.BasicAccountUi import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @Composable fun rememberSelectAccountOpenStatusSheetState(): SelectAccountOpenStatusBottomSheetState { return remember { SelectAccountOpenStatusBottomSheetState() } } class SelectAccountOpenStatusBottomSheetState { internal var statusUiState: StatusUiState? = null internal var visible by mutableStateOf(false) fun show(status: StatusUiState) { this.statusUiState = status visible = true } fun hide() { statusUiState = null visible = false } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun SelectAccountOpenStatusBottomSheet( state: SelectAccountOpenStatusBottomSheetState, ) { val viewModel = koinViewModel() if (!state.visible) { viewModel.clearState() return } val status = state.statusUiState ?: return DisposableEffect(status) { viewModel.initialize(status) onDispose { viewModel.clearState() } } val sheetState = rememberTransientModalBottomSheetState() ModalBottomSheet( sheetState = sheetState, onDismissRequest = { state.hide() viewModel.clearState() } ) { SelectAccountOpenStatusScreen( viewModel = viewModel, onClose = { state.hide() viewModel.clearState() }, ) } } @Composable fun SelectAccountOpenStatusScreen( viewModel: SelectAccountOpenStatusViewModel, onClose: () -> Unit, ) { val uiState by viewModel.uiState.collectAsState() SelectAccountOpenStatusContent( uiState = uiState, onAccountClick = viewModel::onAccountClick, onCancelClick = viewModel::onCancelSearchClick, onSearchFailedClick = viewModel::onSearchFailedClick, ) ConsumeFlow(viewModel.searchedStatusFlow) { onClose() GlobalScreenNavigation.navigate(StatusContextScreenNavKey.create(it)) } } @Composable private fun SelectAccountOpenStatusContent( uiState: SelectAccountOpenStatusUiState, onAccountClick: (LoggedAccount) -> Unit, onCancelClick: () -> Unit, onSearchFailedClick: () -> Unit, ) { Column( modifier = Modifier.fillMaxWidth() .padding(vertical = 16.dp) .padding(bottom = 16.dp) .verticalScroll(rememberScrollState()), ) { Text( modifier = Modifier.padding(horizontal = 16.dp), text = stringResource(LocalizedString.selectAccountOpenStatusTitle), fontSize = 18.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.height(16.dp)) if (!uiState.loadingAccounts && uiState.accountList.isEmpty()) { Box( modifier = Modifier.fillMaxSize() .padding(vertical = 38.dp), contentAlignment = Alignment.Center, ) { Text( text = stringResource(LocalizedString.selectAccountOpenStatusEmpty) ) } } else { val pagerState = rememberPagerState { 2 } LaunchedEffect(uiState.searching) { val target = if (uiState.searching) 1 else 0 pagerState.animateScrollToPage(target) } HorizontalPager( modifier = Modifier.fillMaxWidth(), state = pagerState, userScrollEnabled = false, ) { page -> if (page == 0) { AccountsListContent( uiState = uiState, onAccountClick = onAccountClick, ) } else { SearchContent( uiState = uiState, onCancelClick = onCancelClick, onSearchFailedClick = onSearchFailedClick, ) } } } } } @Composable private fun AccountsListContent( uiState: SelectAccountOpenStatusUiState, onAccountClick: (LoggedAccount) -> Unit, ) { Column( modifier = Modifier.fillMaxWidth() ) { for (account in uiState.accountList) { BasicAccountUi( modifier = Modifier.fillMaxWidth() .clickable { onAccountClick(account) }, account = account, ) HorizontalDivider(thickness = 0.5.dp) } } } @Composable private fun SearchContent( uiState: SelectAccountOpenStatusUiState, onCancelClick: () -> Unit, onSearchFailedClick: () -> Unit, ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(modifier = Modifier.height(16.dp)) if (uiState.searchFailed) { Text( text = stringResource(LocalizedString.selectAccountOpenStatusSearchFailed), style = MaterialTheme.typography.titleMedium, ) } else { Text( text = stringResource( LocalizedString.selectAccountOpenStatusSearchIn, uiState.searchingAccount?.platform?.name.orEmpty(), ), style = MaterialTheme.typography.titleMedium, ) Spacer(modifier = Modifier.height(16.dp)) CircularProgressIndicator( modifier = Modifier.size(48.dp) ) } Spacer(modifier = Modifier.height(16.dp)) Button( onClick = if (uiState.searchFailed) { onSearchFailedClick } else { onCancelClick }, ) { Text( text = stringResource(LocalizedString.cancel) ) } Spacer(modifier = Modifier.height(16.dp)) } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/status/account/SelectAccountOpenStatusUiState.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.status.account import com.zhangke.fread.status.account.LoggedAccount data class SelectAccountOpenStatusUiState( val loadingAccounts: Boolean, val accountList: List, val searching: Boolean, val searchingAccount: LoggedAccount?, val searchFailed: Boolean, ) { fun reset(): SelectAccountOpenStatusUiState { return SelectAccountOpenStatusUiState( loadingAccounts = false, accountList = emptyList(), searching = false, searchingAccount = null, searchFailed = false, ) } companion object { fun default(loadingAccounts: Boolean): SelectAccountOpenStatusUiState { return SelectAccountOpenStatusUiState( loadingAccounts = loadingAccounts, accountList = emptyList(), searching = false, searchingAccount = null, searchFailed = false, ) } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/status/account/SelectAccountOpenStatusViewModel.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.status.account import androidx.lifecycle.ViewModel import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.StatusUiState import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update class SelectAccountOpenStatusViewModel( private val statusProvider: StatusProvider, ) : ViewModel() { private val _uiState = MutableStateFlow( SelectAccountOpenStatusUiState.default(loadingAccounts = false), ) val uiState = _uiState private val _searchedStatusFlow = MutableSharedFlow() val searchedStatusFlow = _searchedStatusFlow private var searchJob: Job? = null private var blogUrl: String? = null fun initialize(statusUiState: StatusUiState) { val protocol = statusUiState.status.platform.protocol val locator = statusUiState.locator this.blogUrl = statusUiState.status.intrinsicBlog.url _uiState.update { it.copy(loadingAccounts = true) } launchInViewModel { val availableAccounts = statusProvider.accountManager .getAllLoggedAccount() .filter { it.platform.protocol == protocol } .filter { it.uri != locator.accountUri } _uiState.update { it.copy( loadingAccounts = false, accountList = availableAccounts, ) } } } fun onAccountClick(account: LoggedAccount) { searchJob?.cancel() _uiState.update { it.copy( searching = true, searchingAccount = account, searchFailed = false, ) } searchJob = launchInViewModel { statusProvider.searchEngine .searchStatusByUrl( protocol = account.platform.protocol, locator = account.locator, url = blogUrl.orEmpty(), ).onSuccess { status -> if (status != null) { _searchedStatusFlow.emit(status) } else { _uiState.update { it.copy(searchFailed = true) } } }.onFailure { _uiState.update { it.copy(searchFailed = true) } } } } fun onSearchFailedClick() { _uiState.update { it.copy( searchFailed = false, searching = false, searchingAccount = null, ) } } fun onCancelSearchClick() { searchJob?.cancel() _uiState.update { it.copy( searching = false, searchingAccount = null, ) } } fun clearState() { _uiState.update { it.reset() } searchJob?.cancel() } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/status/context/StatusContextScreen.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.status.context import androidx.compose.foundation.clickable 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.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.architect.json.globalJson import com.zhangke.framework.blur.LocalBlurController import com.zhangke.framework.blur.applyBlurEffect import com.zhangke.framework.blur.applyBlurSource import com.zhangke.framework.blur.blurEffectContainerColor import com.zhangke.framework.blur.rememberBlurController import com.zhangke.framework.composable.ConsumeOpenScreenFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.LoadErrorLineItem import com.zhangke.framework.composable.LoadingLineItem import com.zhangke.framework.composable.SingleRowTopAppBar import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.TopAppBarColors import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.inline.InlineVideoLazyColumn import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.composable.textString import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.common.composable.ErrorContent import com.zhangke.fread.common.composable.ErrorType import com.zhangke.fread.commonbiz.shared.composable.onStatusMediaClick import com.zhangke.fread.commonbiz.shared.screen.status.account.SelectAccountOpenStatusBottomSheet import com.zhangke.fread.commonbiz.shared.screen.status.account.rememberSelectAccountOpenStatusSheetState import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.model.BlogTranslationUiState import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.ui.BlogAuthorAvatar import com.zhangke.fread.status.ui.ComposedStatusInteraction import com.zhangke.fread.status.ui.StatusListPlaceholder import com.zhangke.fread.status.ui.StatusUi import com.zhangke.fread.status.ui.common.LocalStatusSharedElementConfig import com.zhangke.fread.status.ui.image.OnBlogMediaClick import com.zhangke.fread.status.ui.threads.ThreadsType import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable data class StatusContextScreenNavKey( val locator: PlatformLocator, val serializedStatus: String? = null, val serializedBlog: String? = null, val blogId: String? = null, val platform: BlogPlatform? = null, val blogTranslationUiState: BlogTranslationUiState? = null, ) : NavKey { companion object { fun create(statusUiState: StatusUiState): NavKey { return StatusContextScreenNavKey( locator = statusUiState.locator, serializedStatus = globalJson.encodeToString( kotlinx.serialization.serializer(), statusUiState ), blogTranslationUiState = statusUiState.blogTranslationState, ) } fun create(locator: PlatformLocator, blog: Blog): NavKey { return StatusContextScreenNavKey( locator = locator, serializedBlog = globalJson.encodeToString( kotlinx.serialization.serializer(), blog ), blogTranslationUiState = null, ) } fun create( locator: PlatformLocator, blogId: String, platform: BlogPlatform ): NavKey { return StatusContextScreenNavKey( locator = locator, blogId = blogId, blogTranslationUiState = null, platform = platform, ) } } } @Composable fun StatusContextScreen( locator: PlatformLocator, serializedStatus: String? = null, serializedBlog: String? = null, blogId: String? = null, platform: BlogPlatform? = null, blogTranslationUiState: BlogTranslationUiState? = null, containerViewModel: StatusContextViewModel, ) { val backStack = LocalNavBackStack.currentOrThrow val viewModel = containerViewModel.getSubViewModel( locator = locator, anchorStatus = serializedStatus?.let(globalJson::decodeFromString), blog = serializedBlog?.let { globalJson.decodeFromString(it) }, blogId = blogId, platform = platform, blogTranslationUiState = blogTranslationUiState, ) val uiState by viewModel.uiState.collectAsState() val snackbarHostState = rememberSnackbarHostState() StatusContextContent( uiState = uiState, snackbarHostState = snackbarHostState, onScrolledToAnchor = viewModel::onScrolledToAnchor, onMediaClick = { event -> onStatusMediaClick( navigator = backStack, event = event, ) }, onBackClick = backStack::removeLastOrNull, onAccountClick = viewModel::onAccountClick, onRetryClick = viewModel::onRetryClick, composedStatusInteraction = viewModel.composedStatusInteraction, ) LaunchedEffect(Unit) { viewModel.onPageResume() } ConsumeOpenScreenFlow(viewModel.openScreenFlow) ConsumeSnackbarFlow(snackbarHostState, viewModel.errorMessageFlow) } @Composable private fun StatusContextContent( uiState: StatusContextUiState, snackbarHostState: SnackbarHostState, onRetryClick: () -> Unit, onScrolledToAnchor: () -> Unit, onBackClick: () -> Unit = {}, onAccountClick: (LoggedAccount) -> Unit, onMediaClick: OnBlogMediaClick, composedStatusInteraction: ComposedStatusInteraction, ) { val blurController = rememberBlurController() val enableBlur = uiState.contextStatus.size > 1 CompositionLocalProvider( LocalBlurController provides blurController ) { val surfaceColor = MaterialTheme.colorScheme.surface Scaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, topBar = { SingleRowTopAppBar( modifier = Modifier.applyBlurEffect(enableBlur, surfaceColor), colors = TopAppBarColors.default( containerColor = blurEffectContainerColor( enabled = enableBlur, containerColor = surfaceColor, ) ), title = { Text( text = stringResource(LocalizedString.sharedStatusContextScreenTitle), ) }, navigationIcon = { Toolbar.BackButton(onBackClick = onBackClick) }, actions = { if (uiState.currentAccount != null) { BlogAuthorAvatar( modifier = Modifier.padding(end = 8.dp) .size(28.dp), imageUrl = uiState.currentAccount.avatar, onClick = { onAccountClick(uiState.currentAccount) }, ) } } ) }, content = { contentPaddings -> val contextStatus = uiState.contextStatus if (contextStatus.isEmpty()) { if (uiState.loading) { StatusListPlaceholder(modifier = Modifier.fillMaxSize()) } else { ErrorContent( modifier = Modifier.fillMaxSize(), type = ErrorType.NotFound, errorMessage = uiState.errorMessage?.let { textString(it) }, onRetryClick = onRetryClick, ) } } else { val state = rememberLazyListState() val anchorIndex = uiState.anchorIndex if (!uiState.loading && uiState.needScrollToAnchor && anchorIndex in 0..contextStatus.lastIndex) { LaunchedEffect(anchorIndex) { state.animateScrollToItem(anchorIndex) onScrolledToAnchor() } } InlineVideoLazyColumn( modifier = Modifier .fillMaxSize() .applyBlurSource(enableBlur), state = state, contentPadding = contentPaddings, ) { itemsIndexed( items = contextStatus, ) { index, statusInContext -> StatusInContextUi( modifier = Modifier .fillMaxWidth(), statusInContext = statusInContext, indexInList = index, onMediaClick = onMediaClick, composedStatusInteraction = composedStatusInteraction, ) } if (uiState.loading) { item { LoadingLineItem(modifier = Modifier.fillMaxWidth()) } } else if (uiState.errorMessage != null) { item { LoadErrorLineItem( modifier = Modifier.fillMaxWidth(), errorMessage = uiState.errorMessage, ) } } } } }, ) } } @Composable private fun StatusInContextUi( modifier: Modifier = Modifier, statusInContext: StatusInContext, indexInList: Int, onMediaClick: OnBlogMediaClick, composedStatusInteraction: ComposedStatusInteraction, ) { val selectAccountOpenStatusBottomSheetState = rememberSelectAccountOpenStatusSheetState() val preSharedElementConfig = LocalStatusSharedElementConfig.current val statusSharedElementConfig = remember(preSharedElementConfig) { preSharedElementConfig.copy(label = "status-context") } CompositionLocalProvider( LocalStatusSharedElementConfig provides statusSharedElementConfig ) { when (statusInContext.type) { StatusInContextType.ANCESTOR -> StatusUi( modifier = modifier.clickable { composedStatusInteraction.onStatusClick(statusInContext.status) }, threadsType = if (indexInList == 0) { ThreadsType.FIRST_ANCESTOR } else { ThreadsType.ANCESTOR }, status = statusInContext.status, indexInList = indexInList, onMediaClick = onMediaClick, composedStatusInteraction = composedStatusInteraction, onOpenBlogWithOtherAccountClick = { selectAccountOpenStatusBottomSheetState.show(it) }, ) StatusInContextType.ANCHOR -> StatusUi( modifier = modifier, status = statusInContext.status, indexInList = indexInList, threadsType = if (indexInList == 0) ThreadsType.ANCHOR_FIRST else ThreadsType.ANCHOR, onMediaClick = onMediaClick, detailModel = true, composedStatusInteraction = composedStatusInteraction, onOpenBlogWithOtherAccountClick = { selectAccountOpenStatusBottomSheetState.show(it) }, ) StatusInContextType.DESCENDANT -> StatusUi( modifier = modifier.clickable { composedStatusInteraction.onStatusClick(statusInContext.status) }, status = statusInContext.status, indexInList = indexInList, onMediaClick = onMediaClick, threadsType = ThreadsType.NONE, composedStatusInteraction = composedStatusInteraction, onOpenBlogWithOtherAccountClick = { selectAccountOpenStatusBottomSheetState.show(it) }, ) StatusInContextType.DESCENDANT_ANCHOR -> StatusUi( modifier = modifier.clickable { composedStatusInteraction.onStatusClick(statusInContext.status) }, threadsType = ThreadsType.FIRST_ANCESTOR, status = statusInContext.status, indexInList = indexInList, onMediaClick = onMediaClick, composedStatusInteraction = composedStatusInteraction, onOpenBlogWithOtherAccountClick = { selectAccountOpenStatusBottomSheetState.show(it) }, ) StatusInContextType.DESCENDANT_WITH_ANCESTOR_DESCENDANT -> StatusUi( modifier = modifier.clickable { composedStatusInteraction.onStatusClick(statusInContext.status) }, threadsType = ThreadsType.ANCESTOR, status = statusInContext.status, indexInList = indexInList, onMediaClick = onMediaClick, composedStatusInteraction = composedStatusInteraction, onOpenBlogWithOtherAccountClick = { selectAccountOpenStatusBottomSheetState.show(it) }, ) StatusInContextType.DESCENDANT_WITH_ANCESTOR -> StatusUi( modifier = modifier.clickable { composedStatusInteraction.onStatusClick(statusInContext.status) }, threadsType = ThreadsType.ANCHOR, status = statusInContext.status, indexInList = indexInList, onMediaClick = onMediaClick, composedStatusInteraction = composedStatusInteraction, onOpenBlogWithOtherAccountClick = { selectAccountOpenStatusBottomSheetState.show(it) }, ) } } SelectAccountOpenStatusBottomSheet(selectAccountOpenStatusBottomSheetState) } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/status/context/StatusContextSubViewModel.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.status.context import com.zhangke.framework.composable.emitInViewModel import com.zhangke.framework.composable.textOf import com.zhangke.framework.composable.toTextStringOrNull import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.lifecycle.SubViewModel import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.mixed.MixedStatusRepo import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.feeds.IInteractiveHandler import com.zhangke.fread.commonbiz.shared.feeds.InteractiveHandleResult import com.zhangke.fread.commonbiz.shared.feeds.InteractiveHandler import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.model.BlogTranslationUiState import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.model.updateFollowingState import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.status.model.DescendantStatus import com.zhangke.fread.status.status.model.Status import com.zhangke.fread.status.status.model.StatusContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update class StatusContextSubViewModel( private val mixedStatusRepo: MixedStatusRepo, private val statusProvider: StatusProvider, private val statusUpdater: StatusUpdater, private val refactorToNewStatus: RefactorToNewStatusUseCase, private val statusUiStateAdapter: StatusUiStateAdapter, private val locator: PlatformLocator, anchorStatus: StatusUiState?, blog: Blog?, private val blogId: String?, private val blogTranslationUiState: BlogTranslationUiState?, private val platform: BlogPlatform?, ) : SubViewModel(), IInteractiveHandler by InteractiveHandler( statusProvider = statusProvider, statusUpdater = statusUpdater, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, ) { private val _uiState = MutableStateFlow( StatusContextUiState( contextStatus = emptyList(), loading = false, needScrollToAnchor = true, errorMessage = null, currentAccount = null, ) ) val uiState = _uiState.asStateFlow() private val anchorStatus: StatusUiState? = anchorStatus ?: blog?.let { statusUiStateAdapter.toStatusUiStateSnapshot( locator = locator, status = Status.NewBlog(it), blogTranslationState = blogTranslationUiState, ) } private var anchorAuthorFollowing: Boolean? = null init { if (this.anchorStatus != null) { _uiState.update { it.copy( contextStatus = listOf( StatusInContext( type = StatusInContextType.ANCHOR, status = this.anchorStatus ) ) ) } } initInteractiveHandler( coroutineScope = viewModelScope, onInteractiveHandleResult = { when (it) { is InteractiveHandleResult.UpdateStatus -> { val innerAnchorStatus = _uiState.value.contextStatus .firstOrNull { status -> status.type == StatusInContextType.ANCHOR } if (innerAnchorStatus?.status != it.status) { updateStatus(it.status) } } is InteractiveHandleResult.DeleteStatus -> { deleteStatus(it.statusId) } is InteractiveHandleResult.UpdateFollowState -> { anchorAuthorFollowing = it.following updateAnchorFollowingState() } } }, ) launchInViewModel { statusProvider.accountManager .getAllLoggedAccount() .firstOrNull { it.locator == locator } ?.let { account -> _uiState.update { state -> state.copy(currentAccount = account) } } } } fun onPageResume() { launchInViewModel { loadStatusContext() } val anchorAuthor = this.anchorStatus?.status?.intrinsicBlog?.author ?: _uiState.value.anchorStatus?.status?.status?.intrinsicBlog?.author if (anchorAuthor != null) { loadAnchorFollowingState(anchorAuthor) } } fun onRetryClick() { onPageResume() } fun onScrolledToAnchor() { _uiState.update { state -> state.copy(needScrollToAnchor = false) } } fun onAccountClick(account: LoggedAccount) { statusProvider.screenProvider .getUserDetailScreen( locator = locator, uri = account.uri, userId = account.id, )?.let { mutableOpenScreenFlow.emitInViewModel(it) } } private suspend fun loadStatusContext() { _uiState.update { it.copy(loading = true) } var anchorStatus = this.anchorStatus if (anchorStatus == null && !blogId.isNullOrEmpty() && platform != null) { anchorStatus = loadStatus( blogId = blogId, blogUri = null, platform = platform, ) if (anchorStatus != null) { loadAnchorFollowingState(anchorStatus.status.intrinsicBlog.author) } } if (anchorStatus == null) { _uiState.update { it.copy(errorMessage = textOf("Blog not found.")) } return } statusProvider.statusResolver .getStatusContext(locator, anchorStatus.status) .map { statusContext -> val status = statusContext.status?.let { statusUpdater.update( it.copy( blogTranslationState = blogTranslationUiState ?: it.blogTranslationState ) ) if (anchorAuthorFollowing != null) { it.updateFollowingState(anchorAuthorFollowing!!) } else { it } } ?: loadStatus(anchorStatus.status.intrinsicBlog) ?: anchorStatus if (anchorAuthorFollowing != null) { statusContext.copy( status = status.updateFollowingState(anchorAuthorFollowing!!) ) } else { statusContext.copy(status = status) } } .onSuccess { statusContext -> _uiState.update { state -> state.copy( contextStatus = buildContextStatus(statusContext), loading = false, errorMessage = null, ) } statusUpdater.update(statusContext.status!!) }.onFailure { _uiState.update { state -> state.copy( loading = false, errorMessage = it.toTextStringOrNull(), ) } } } private suspend fun loadStatus( blog: Blog ): StatusUiState? { return loadStatus( blogId = blog.id, blogUri = blog.url, platform = blog.platform, ) } private suspend fun loadStatus( blogId: String?, blogUri: String?, platform: BlogPlatform, ): StatusUiState? { return statusProvider.statusResolver .getStatus( locator = locator, blogId = blogId, blogUri = blogUri, platform = platform, ).map { it.copy(blogTranslationState = blogTranslationUiState ?: it.blogTranslationState) if (anchorAuthorFollowing != null) { it.updateFollowingState(anchorAuthorFollowing!!) } else { it } } .onSuccess { statusUpdater.update(it) } .getOrNull() } private fun buildContextStatus( statusContext: StatusContext, ): List { val contextStatus = mutableListOf() contextStatus += statusContext.ancestors.sortedBy { it.status.createAt.epochMillis } .map { StatusInContext(it, StatusInContextType.ANCESTOR) } statusContext.status?.let { contextStatus += StatusInContext(it, StatusInContextType.ANCHOR) } for (descendant in statusContext.descendants) { contextStatus.addAll(descendant.expandToContextStatus(null)) } return contextStatus } private fun DescendantStatus.expandToContextStatus( parentType: StatusInContextType?, ): List { val type = if (this.descendantStatus != null) { if (parentType == null) { StatusInContextType.DESCENDANT_ANCHOR } else { StatusInContextType.DESCENDANT_WITH_ANCESTOR_DESCENDANT } } else { if (parentType == null) { StatusInContextType.DESCENDANT } else { StatusInContextType.DESCENDANT_WITH_ANCESTOR } } val list = mutableListOf() val statusInContext = StatusInContext(status, type) list.add(statusInContext) if (this.descendantStatus != null) { list.addAll(this.descendantStatus!!.expandToContextStatus(type)) } return list } private suspend fun updateStatus(newStatus: StatusUiState) { mixedStatusRepo.updateStatus(newStatus) _uiState.update { state -> val contextStatus = state.contextStatus.map { item -> item.copy( status = if (item.status.status.intrinsicBlog.id == newStatus.status.intrinsicBlog.id) { newStatus } else { item.status } ) } state.copy(contextStatus = contextStatus) } updateAnchorFollowingState() } private fun loadAnchorFollowingState(author: BlogAuthor) { launchInViewModel { statusProvider.statusResolver .isFollowing( locator = locator, target = author, )?.onSuccess { following -> anchorAuthorFollowing = following updateAnchorFollowingState() } } } private fun updateAnchorFollowingState() { _uiState.update { state -> state.copy( contextStatus = state.contextStatus.map { item -> if (item.type == StatusInContextType.ANCHOR && anchorAuthorFollowing != null) { item.copy( status = item.status.updateFollowingState(anchorAuthorFollowing!!) ) } else { item } } ) } } private fun deleteStatus(statusId: String) { _uiState.update { state -> state.copy( contextStatus = state.contextStatus.filter { it.status.status.id != statusId } ) } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/status/context/StatusContextUiState.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.status.context import com.zhangke.framework.composable.TextString import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.StatusUiState data class StatusContextUiState( val contextStatus: List, val loading: Boolean, val needScrollToAnchor: Boolean, val currentAccount: LoggedAccount?, val errorMessage: TextString?, ) { val anchorIndex: Int get() = contextStatus.indexOfFirst { it.type == StatusInContextType.ANCHOR } val anchorStatus: StatusInContext? get() = contextStatus.getOrNull(anchorIndex) } data class StatusInContext( val status: StatusUiState, val type: StatusInContextType, ) enum class StatusInContextType { ANCHOR, ANCESTOR, DESCENDANT,// no descendant DESCENDANT_ANCHOR, DESCENDANT_WITH_ANCESTOR_DESCENDANT, DESCENDANT_WITH_ANCESTOR, } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/status/context/StatusContextViewModel.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.status.context import com.zhangke.framework.lifecycle.ContainerViewModel import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.mixed.MixedStatusRepo import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.model.BlogTranslationUiState import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.platform.BlogPlatform class StatusContextViewModel ( private val mixedStatusRepo: MixedStatusRepo, private val statusProvider: StatusProvider, private val statusUpdater: StatusUpdater, private val statusUiStateAdapter: StatusUiStateAdapter, private val refactorToNewStatus: RefactorToNewStatusUseCase, ) : ContainerViewModel() { override fun createSubViewModel(params: Params): StatusContextSubViewModel { return StatusContextSubViewModel( mixedStatusRepo = mixedStatusRepo, statusProvider = statusProvider, statusUpdater = statusUpdater, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, locator = params.locator, anchorStatus = params.anchorStatus, blog = params.blog, blogId = params.blogId, blogTranslationUiState = params.blogTranslationUiState, platform = params.platform, ) } fun getSubViewModel( locator: PlatformLocator, anchorStatus: StatusUiState?, blog: Blog?, blogId: String?, blogTranslationUiState: BlogTranslationUiState?, platform: BlogPlatform?, ): StatusContextSubViewModel { return obtainSubViewModel( Params( locator = locator, anchorStatus = anchorStatus, blog = blog, blogId = blogId, blogTranslationUiState = blogTranslationUiState, platform = platform, ) ) } class Params( val locator: PlatformLocator, val anchorStatus: StatusUiState?, val blog: Blog?, val blogId: String?, val blogTranslationUiState: BlogTranslationUiState?, val platform: BlogPlatform?, ) : SubViewModelParams() { override val key: String = anchorStatus?.status?.id + blog?.id + locator + blogId + platform } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/video/FullVideoScreen.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen.video import androidx.compose.runtime.Composable import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.utils.toPlatformUri import com.zhangke.fread.status.ui.video.full.FullScreenVideoPlayer import kotlinx.serialization.Serializable @Serializable data class FullVideoScreenNavKey(val uri: String): NavKey @Composable fun FullVideoScreen(uri: String) { val backStack = LocalNavBackStack.currentOrThrow FullScreenVideoPlayer( uri = uri.toPlatformUri(), onBackClick = backStack::removeLastOrNull, ) } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/usecase/PublishPostOnMultiAccountUseCase.kt ================================================ package com.zhangke.fread.commonbiz.shared.usecase import com.zhangke.framework.utils.exceptionOrThrow import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.publish.PublishingPost import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.supervisorScope class PublishPostOnMultiAccountUseCase ( private val statusProvider: StatusProvider, ) { suspend operator fun invoke( accounts: List, publishingPost: PublishingPost, ): Result { val publishManager = statusProvider.publishManager val results = supervisorScope { accounts.map { async { it to publishManager.publish(it, publishingPost) } }.awaitAll() } if (results.any { it.second.isFailure }) { val e = results.first { it.second.isFailure }.second.exceptionOrThrow() val successAccount = results.filter { it.second.isSuccess }.map { it.first } val failedAccounts = results.filter { it.second.isFailure }.map { it.first } return Result.failure( PublishingPartFailed( successAccount = successAccount.map { it.uri.toString() }, failedAccounts = failedAccounts.map { it.uri.toString() }, e = e, ) ) } else { return Result.success(Unit) } } } class PublishingPartFailed( val successAccount: List, val failedAccounts: List, val e: Throwable, ) : RuntimeException() ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/usecase/RefactorToNewBlogUseCase.kt ================================================ package com.zhangke.fread.commonbiz.shared.usecase import com.zhangke.fread.status.status.model.Status class RefactorToNewBlogUseCase () { operator fun invoke(status: Status): Status.NewBlog { return when (status) { is Status.NewBlog -> status is Status.Reblog -> Status.NewBlog( blog = status.reblog, ) } } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/usecase/RefactorToNewStatusUseCase.kt ================================================ package com.zhangke.fread.commonbiz.shared.usecase import com.zhangke.fread.status.model.StatusUiState class RefactorToNewStatusUseCase ( private val refactorToNewBlog: RefactorToNewBlogUseCase, ) { operator fun invoke(status: StatusUiState): StatusUiState { return status.copy( status = refactorToNewBlog(status.status), ) } } ================================================ FILE: commonbiz/sharedscreen/src/commonMain/kotlin/com/zhangke/fread/commonbiz/shared/utils/LoadableStatusController.kt ================================================ package com.zhangke.fread.commonbiz.shared.utils import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.TextString import com.zhangke.framework.controller.CommonLoadableController import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.richtext.preParse import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch open class LoadableStatusController( protected val coroutineScope: CoroutineScope, ) { private val loadableController = CommonLoadableController( coroutineScope, onPostSnackMessage = { coroutineScope.launch { mutableErrorMessageFlow.emit(it) } }, ) val mutableUiState = loadableController.mutableUiState val uiState = loadableController.uiState private val mutableErrorMessageFlow = MutableSharedFlow() val errorMessageFlow: SharedFlow = mutableErrorMessageFlow private val _openScreenFlow = MutableSharedFlow() val openScreenFlow: SharedFlow get() = _openScreenFlow open fun onRefresh( locator: PlatformLocator, refreshFunction: suspend () -> Result>, ) { loadableController.onRefresh { refreshFunction().map { list -> list.preParse() list } } } open fun onLoadMore( locator: PlatformLocator, loadMoreFunction: suspend (maxId: String) -> Result>, ) { val latestId = loadableController.uiState.value.dataList.lastOrNull()?.status?.id ?: return loadableController.onLoadMore { loadMoreFunction(latestId).map { list -> list.preParse() list } } } } ================================================ FILE: commonbiz/sharedscreen/src/iosMain/kotlin/com/zhangke/fread/commonbiz/shared/SharedScreenIosModule.kt ================================================ package com.zhangke.fread.commonbiz.shared import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.zhangke.fread.common.documentDirectory import com.zhangke.fread.commonbiz.shared.db.SelectedAccountPublishingDatabase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import org.koin.core.module.Module actual fun Module.createPlatformModule() { single { val dbFilePath = getDBFilePath(SelectedAccountPublishingDatabase.DB_NAME) Room.databaseBuilder( name = dbFilePath, ).setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() } } private fun getDBFilePath(dbName: String): String { return documentDirectory() + "/$dbName" } ================================================ FILE: commonbiz/sharedscreen/src/iosMain/kotlin/com/zhangke/fread/commonbiz/shared/composable/WebViewPreviewer.ios.kt ================================================ package com.zhangke.fread.commonbiz.shared.composable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @Composable actual fun WebViewPreviewer(html: String, modifier: Modifier) { // TODO: Not implemented yet } ================================================ FILE: commonbiz/sharedscreen/src/iosMain/kotlin/com/zhangke/fread/commonbiz/shared/screen/ImageViewerScreen.ios.kt ================================================ package com.zhangke.fread.commonbiz.shared.screen import com.seiko.imageloader.model.ImageResult internal actual fun ImageResult.aspectRatio(): Float? { return when (this) { is ImageResult.OfBitmap -> bitmap.width.toFloat() / bitmap.height is ImageResult.OfImage -> image.width.toFloat() / image.height else -> null } } ================================================ FILE: commonbiz/status-ui/.gitignore ================================================ /build ================================================ FILE: commonbiz/status-ui/build.gradle.kts ================================================ plugins { id("fread.project.framework.kmp") id("com.google.devtools.ksp") } android { namespace = "com.zhangke.fread.statusui" sourceSets { getByName("main") { res.srcDirs("src/commonMain/res") resources.srcDirs("src/commonMain/resources") } } } kotlin { sourceSets { commonMain { dependencies { implementation(project(path = ":framework")) implementation(project(path = ":commonbiz:common")) implementation(project(":commonbiz:analytics")) implementation(project(path = ":bizframework:status-provider")) implementation(compose.components.resources) implementation(libs.arrow.core) implementation(libs.androidx.annotation) implementation(libs.imageLoader) implementation(libs.ktml) implementation(libs.krouter.runtime) implementation(libs.androidx.constraintlayout.compose.kmp) implementation(libs.haze) implementation(libs.haze.materials) implementation(project(":thirds:halilibo-richtext-ui")) implementation(project(":thirds:halilibo-richtext-material3")) } } commonTest { dependencies { implementation(kotlin("test")) } } androidMain { dependencies { implementation(compose.preview) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.bundles.androidx.media3) implementation(libs.okhttp3) implementation(libs.okhttp3.logging) implementation(libs.auto.service.annotations) } } } } dependencies { add("kspAndroid", libs.androidx.room.compiler) add("kspAndroid", libs.auto.service.ksp) } compose { resources { publicResClass = true packageOfResClass = "com.zhangke.fread.statusui" generateResClass = always } } ================================================ FILE: commonbiz/status-ui/consumer-rules.pro ================================================ ================================================ FILE: commonbiz/status-ui/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: commonbiz/status-ui/src/androidMain/kotlin/com/zhangke/fread/status/ui/StatusPlaceHolder.preview.kt ================================================ package com.zhangke.fread.status.ui import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.zhangke.framework.architect.theme.FreadTheme @Preview @Composable fun StatusPlaceHolderPreview() { FreadTheme { StatusPlaceHolder( modifier = Modifier.fillMaxWidth(), ) } } ================================================ FILE: commonbiz/status-ui/src/androidMain/kotlin/com/zhangke/fread/status/ui/common/FormattingTimeText.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit import com.zhangke.fread.status.utils.DateTimeFormatter import java.util.Date @Composable fun FormattingTimeText( date: Date, modifier: Modifier = Modifier, color: Color = Color.Unspecified, fontSize: TextUnit = TextUnit.Unspecified, fontStyle: FontStyle? = null, fontWeight: FontWeight? = null, fontFamily: FontFamily? = null, letterSpacing: TextUnit = TextUnit.Unspecified, textDecoration: TextDecoration? = null, textAlign: TextAlign? = null, lineHeight: TextUnit = TextUnit.Unspecified, overflow: TextOverflow = TextOverflow.Clip, softWrap: Boolean = true, maxLines: Int = Int.MAX_VALUE, minLines: Int = 1, onTextLayout: ((TextLayoutResult) -> Unit)? = null, style: TextStyle = LocalTextStyle.current ) { val timeText by produceState("") { value = DateTimeFormatter.format(date.time) } Text( text = timeText, modifier = modifier, color = color, fontSize = fontSize, fontStyle = fontStyle, fontWeight = fontWeight, fontFamily = fontFamily, letterSpacing = letterSpacing, textDecoration = textDecoration, textAlign = textAlign, lineHeight = lineHeight, overflow = overflow, softWrap = softWrap, maxLines = maxLines, minLines = minLines, onTextLayout = onTextLayout, style = style, ) } ================================================ FILE: commonbiz/status-ui/src/androidMain/kotlin/com/zhangke/fread/status/ui/poll/BlogPollOption.preview.kt ================================================ package com.zhangke.fread.status.ui.poll import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement 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.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @Preview(backgroundColor = 0xffffffff) @Composable private fun PreviewMoreLineBlogPollOption() { Column( modifier = Modifier .fillMaxSize() .background(color = Color.White) .padding(horizontal = 15.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { BlogPollOption( modifier = Modifier .fillMaxWidth(), optionContent = "12344", selected = true, votable = true, showProgress = true, progress = 0F, onClick = {}, ) BlogPollOption( modifier = Modifier .fillMaxWidth() .padding(top = 10.dp), optionContent = "12344", selected = true, votable = true, showProgress = true, progress = 0F, onClick = {}, ) BlogPollOption( modifier = Modifier .fillMaxWidth() .padding(top = 10.dp), optionContent = "12344", selected = true, votable = true, progress = 0F, showProgress = true, onClick = {}, ) BlogPollOption( modifier = Modifier .fillMaxWidth() .padding(top = 10.dp), optionContent = "123441234412344123441234412344123441234412344123441234412344123441234412344123441234412344123441234412344123441234412344123441234412344", progress = 0.5F, selected = true, votable = true, showProgress = true, onClick = {}, ) } } ================================================ FILE: commonbiz/status-ui/src/androidMain/kotlin/com/zhangke/fread/status/ui/utils/ScreenSize.android.kt ================================================ package com.zhangke.fread.status.ui.utils import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @ReadOnlyComposable @Composable actual fun getScreenWidth(): Dp { val configuration = LocalConfiguration.current return configuration.screenWidthDp.dp } @ReadOnlyComposable @Composable actual fun getScreenHeight(): Dp { val configuration = LocalConfiguration.current return configuration.screenHeightDp.dp } ================================================ FILE: commonbiz/status-ui/src/androidMain/kotlin/com/zhangke/fread/status/ui/video/BlogVideos.kt ================================================ package com.zhangke.fread.status.ui.video import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.zhangke.framework.blurhash.blurhash import com.zhangke.framework.utils.toPlatformUri import com.zhangke.fread.status.blog.BlogMedia import com.zhangke.fread.status.ui.image.BlogMediaClickEvent import com.zhangke.fread.status.ui.image.OnBlogMediaClick import com.zhangke.fread.status.ui.image.decideAspect import com.zhangke.fread.status.ui.video.inline.InlineVideo @Composable actual fun BlogVideos( mediaList: List, hideContent: Boolean, indexInList: Int, onMediaClick: OnBlogMediaClick, ) { val videoMedia = mediaList.first() SingleBlogInlineVideo( videoMedia = videoMedia, hideContent = hideContent, indexInList = indexInList, onMediaClick = onMediaClick, ) } @Composable private fun SingleBlogInlineVideo( videoMedia: BlogMedia, hideContent: Boolean, indexInList: Int, onMediaClick: OnBlogMediaClick, ) { val aspect = videoMedia.meta.decideAspect(1.78F) val modifier = Modifier .clip(RoundedCornerShape(8.dp)) .fillMaxWidth() .blurhash(videoMedia.blurhash) Box( modifier = modifier ) { if (!hideContent) { InlineVideo( aspectRatio = aspect, coverImage = videoMedia.previewUrl, indexInList = indexInList, uri = remember(videoMedia.url) { videoMedia.url.toPlatformUri() }, onClick = { onMediaClick( BlogMediaClickEvent.BlogVideoClickEvent( index = indexInList, media = videoMedia, ) ) }, ) } } } ================================================ FILE: commonbiz/status-ui/src/androidMain/kotlin/com/zhangke/fread/status/ui/video/LocalInlineVideoPlayer.kt ================================================ package com.zhangke.fread.status.ui.video import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.staticCompositionLocalOf import androidx.media3.exoplayer.ExoPlayer val LocalInlineVideoPlayer: ProvidableCompositionLocal = staticCompositionLocalOf { null } fun provideExoPlayer(){ } ================================================ FILE: commonbiz/status-ui/src/androidMain/kotlin/com/zhangke/fread/status/ui/video/inline/InlineVideo.kt ================================================ package com.zhangke.fread.status.ui.video.inline import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio 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.material.icons.Icons import androidx.compose.material.icons.filled.PlayCircleOutline import androidx.compose.material.icons.filled.VolumeOff import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.media3.common.util.UnstableApi import com.seiko.imageloader.ui.AutoSizeImage import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.inline.LocalPlayableIndexRecorder import com.zhangke.framework.composable.noRippleClick import com.zhangke.framework.composable.video.VideoPlayer import com.zhangke.framework.composable.video.rememberVideoPlayerController import com.zhangke.framework.utils.PlatformUri import com.zhangke.fread.common.config.LocalFreadConfigManager @androidx.annotation.OptIn(UnstableApi::class) @Composable fun InlineVideo( aspectRatio: Float?, coverImage: String?, indexInList: Int, style: InlineVideoPlayerStyle = InlineVideoPlayerDefault.defaultStyle, uri: PlatformUri, onClick: () -> Unit, ) { val playableIndexRecorder = LocalPlayableIndexRecorder.current playableIndexRecorder?.recordePlayableIndex(indexInList) val playWhenReady = playableIndexRecorder != null && playableIndexRecorder.currentActiveIndex == indexInList InlineVideoShell( aspectRatio = aspectRatio ?: style.defaultMediaAspect, style = style, ) { val freadConfigManager = LocalFreadConfigManager.current InlineVideoPlayer( uri = uri, coverImage = coverImage, autoPlay = freadConfigManager.autoPlayInlineVideo, playWhenReady = playWhenReady, onClick = onClick, onPlayManually = { playableIndexRecorder?.changeActiveIndex(indexInList) }, ) } } @Composable private fun InlineVideoShell( aspectRatio: Float, style: InlineVideoPlayerStyle, content: @Composable () -> Unit, ) { val fixedAspectRatio = aspectRatio .coerceAtLeast(style.minAspect) .coerceAtMost(style.maxAspect) Box( modifier = Modifier .fillMaxWidth() .aspectRatio(fixedAspectRatio), content = { content() }, ) } @androidx.annotation.OptIn(UnstableApi::class) @Composable private fun InlineVideoPlayer( uri: PlatformUri, coverImage: String?, autoPlay: Boolean, playWhenReady: Boolean, onClick: () -> Unit, onPlayManually: () -> Unit, ) { Box( modifier = Modifier .fillMaxSize() .background(Color.Black) .noRippleClick(onClick = onClick) ) { if (autoPlay && playWhenReady) { val videoController = rememberVideoPlayerController( mediaUrl = uri.toString(), initialContentScale = ContentScale.Fit, autoPlay = true, initialMuted = true, isLooping = false, ) VideoPlayer( modifier = Modifier.fillMaxSize().noRippleClick(onClick = onClick), controller = videoController, ) InlineVideoControlPanel( playEnded = videoController.hasPlaybackEnded, mute = videoController.isMuted, onPlayClick = { onPlayManually() videoController.play() }, onMuteClick = { mute -> if (mute) { videoController.mute() } else { videoController.unmute() } }, ) } else { Box(modifier = Modifier.fillMaxSize()) { AutoSizeImage( url = coverImage.orEmpty(), modifier = Modifier .fillMaxSize(), contentScale = ContentScale.Crop, contentDescription = null, ) PlayVideoIconButton( modifier = Modifier .size(36.dp) .align(Alignment.Center), onClick = { if (autoPlay) { onPlayManually() } else { onClick() } }, ) } } } } @Composable private fun InlineVideoControlPanel( playEnded: Boolean, mute: Boolean, onPlayClick: () -> Unit, onMuteClick: (mute: Boolean) -> Unit, ) { Box(modifier = Modifier.fillMaxSize()) { if (playEnded) { PlayVideoIconButton( modifier = Modifier .size(42.dp) .align(Alignment.Center), onClick = onPlayClick, ) } IconButton( modifier = Modifier .align(Alignment.BottomEnd) .padding(end = 10.dp, bottom = 6.dp), onClick = { onMuteClick(!mute) }, ) { val icon = if (mute) { Icons.Default.VolumeOff } else { Icons.Default.VolumeUp } Icon( imageVector = icon, contentDescription = if (mute) "unmute" else "mute", tint = Color.White, ) } } } @Composable internal fun PlayVideoIconButton( modifier: Modifier, onClick: () -> Unit, ) { SimpleIconButton( modifier = modifier, onClick = onClick, imageVector = Icons.Default.PlayCircleOutline, tint = Color.White, contentDescription = "Play", ) } data class InlineVideoPlayerStyle( val radius: Dp, val defaultMediaAspect: Float, val minAspect: Float, val maxAspect: Float, ) object InlineVideoPlayerDefault { val defaultStyle = InlineVideoPlayerStyle( radius = 8.dp, defaultMediaAspect = 1.0F, maxAspect = 2.5F, minAspect = 0.7F, ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/composeResources/drawable/ic_drag_indicator.xml ================================================ ================================================ FILE: commonbiz/status-ui/src/commonMain/composeResources/drawable/ic_format_quote.xml ================================================ ================================================ FILE: commonbiz/status-ui/src/commonMain/composeResources/drawable/ic_format_quote_in_left.xml ================================================ ================================================ FILE: commonbiz/status-ui/src/commonMain/composeResources/drawable/ic_mode_edit.xml ================================================ ================================================ FILE: commonbiz/status-ui/src/commonMain/composeResources/drawable/ic_more.xml ================================================ ================================================ FILE: commonbiz/status-ui/src/commonMain/composeResources/drawable/ic_post_status_spoiler.xml ================================================ ================================================ FILE: commonbiz/status-ui/src/commonMain/composeResources/drawable/ic_share.xml ================================================ ================================================ FILE: commonbiz/status-ui/src/commonMain/composeResources/drawable/ic_status_comment.xml ================================================ ================================================ FILE: commonbiz/status-ui/src/commonMain/composeResources/drawable/ic_status_forward.xml ================================================ ================================================ FILE: commonbiz/status-ui/src/commonMain/composeResources/drawable/img_banner_background.xml ================================================ ================================================ FILE: commonbiz/status-ui/src/commonMain/composeResources/drawable/status_ui_baseline_visibility_off_24.xml ================================================ ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/BlogAuthorAvatar.kt ================================================ package com.zhangke.fread.status.ui import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.seiko.imageloader.model.ImageAction import com.seiko.imageloader.model.ImageRequest import com.seiko.imageloader.rememberImageActionPainter import com.seiko.imageloader.ui.AutoSizeBox import com.zhangke.framework.composable.freadPlaceholder @Composable fun BlogAuthorAvatar( modifier: Modifier, reblogAvatar: String?, authorAvatar: String?, onClick: (() -> Unit)? = null, ) { if (reblogAvatar.isNullOrEmpty()) { BlogAuthorAvatar( modifier = modifier, onClick = onClick, imageUrl = authorAvatar, ) } else { Box(modifier = modifier) { Box( modifier = Modifier .fillMaxSize() .padding(end = 6.dp, bottom = 6.dp) ) { BlogAuthorAvatar( modifier = Modifier.fillMaxSize(), onClick = onClick, imageUrl = authorAvatar, ) } BlogAuthorAvatar( modifier = Modifier .size(18.dp) .align(Alignment.BottomEnd), onClick = onClick, imageUrl = reblogAvatar, ) } } } @Composable fun BlogAuthorAvatar( modifier: Modifier, imageUrl: String?, onClick: (() -> Unit)? = null, ) { AutoSizeBox( request = remember(imageUrl) { ImageRequest(imageUrl.orEmpty()) }, ) { action -> Image( painter = rememberImageActionPainter(action), contentDescription = "Avatar", modifier = modifier .clip(CircleShape) .freadPlaceholder(action !is ImageAction.Success) .let { if (onClick == null) { it } else { it.clickable { onClick() } } }, ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/BlogAuthorUi.kt ================================================ package com.zhangke.fread.status.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 com.zhangke.framework.composable.StyledTextButton import com.zhangke.framework.composable.TextButtonStyle import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.common.browser.launchWebTabInApp import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.ui.richtext.FreadRichText import com.zhangke.fread.status.ui.style.StatusInfoStyleDefaults import org.jetbrains.compose.resources.stringResource @Composable fun BlogAuthorUi( modifier: Modifier, author: BlogAuthor, onClick: (BlogAuthor) -> Unit, onUrlClick: (String) -> Unit, ) { Column(modifier = modifier.clickable { onClick(author) }) { Row( modifier = Modifier.fillMaxWidth(), ) { BlogAuthorAvatar( modifier = Modifier .padding(start = 16.dp, top = 8.dp) .size(StatusInfoStyleDefaults.avatarSize), imageUrl = author.avatar, ) Column( modifier = Modifier.weight(1F) .padding(start = 16.dp, top = 8.dp, end = 16.dp), ) { FreadRichText( modifier = Modifier.fillMaxWidth(), richText = author.humanizedName, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 16.sp, onUrlClick = onUrlClick, ) Text( modifier = Modifier.fillMaxWidth().padding(top = 2.dp), textAlign = TextAlign.Start, text = author.webFinger.toString(), maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelMedium, ) FreadRichText( modifier = Modifier.fillMaxWidth().padding(top = 4.dp), content = author.description, emojis = author.emojis, mentions = emptyList(), tags = emptyList(), onHashtagClick = {}, onMentionClick = {}, maxLines = 1, onUrlClick = onUrlClick, ) } } HorizontalDivider(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) } } @Composable fun RecommendAuthorUi( modifier: Modifier, locator: PlatformLocator, author: BlogAuthor, following: Boolean, composedStatusInteraction: ComposedStatusInteraction, ) { BaseBlogAuthor( modifier = modifier, locator = locator, author = author, following = following, composedStatusInteraction = composedStatusInteraction, ) } @Composable private fun BaseBlogAuthor( modifier: Modifier, locator: PlatformLocator, author: BlogAuthor, following: Boolean, composedStatusInteraction: ComposedStatusInteraction, ) { val browserLauncher = LocalActivityBrowserLauncher.current val coroutineScope = rememberCoroutineScope() Box( modifier = modifier.fillMaxWidth() .clickable { composedStatusInteraction.onUserInfoClick(locator, author) }, ) { Row( modifier = Modifier.padding(bottom = 8.dp) ) { BlogAuthorAvatar( modifier = Modifier .padding(start = 16.dp, top = 8.dp) .size(StatusInfoStyleDefaults.avatarSize), imageUrl = author.avatar, ) Column( modifier = Modifier .padding(start = 16.dp, top = 2.dp, end = 8.dp) .fillMaxWidth() ) { Row(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.weight(1F).align(Alignment.CenterVertically)) { FreadRichText( modifier = Modifier.fillMaxWidth(), richText = author.humanizedName, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 16.sp, onUrlClick = { browserLauncher.launchWebTabInApp(coroutineScope, it, locator) }, ) Text( modifier = Modifier.fillMaxWidth().padding(top = 2.dp), textAlign = TextAlign.Start, text = author.webFinger.toString(), maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelMedium, ) } StyledTextButton( modifier = Modifier.align(Alignment.CenterVertically), text = if (following) { stringResource(LocalizedString.statusUiUnfollow) } else { stringResource(LocalizedString.statusUiFollow) }, style = TextButtonStyle.STANDARD, onClick = { if (following) { composedStatusInteraction.onUnfollowClick(locator, author) } else { composedStatusInteraction.onFollowClick(locator, author) } }, ) } FreadRichText( modifier = Modifier .padding(end = 8.dp), content = author.description, emojis = author.emojis, mentions = emptyList(), tags = emptyList(), onMentionClick = { composedStatusInteraction.onMentionClick(locator, it) }, onHashtagClick = { composedStatusInteraction.onHashtagInStatusClick(locator, it) }, overflow = TextOverflow.Ellipsis, maxLines = 3, onUrlClick = { browserLauncher.launchWebTabInApp(coroutineScope, it, locator) }, ) } } HorizontalDivider(modifier = Modifier.fillMaxWidth()) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/BlogContent.kt ================================================ package com.zhangke.fread.status.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import com.zhangke.framework.architect.theme.inverseOnSurfaceDark import com.zhangke.framework.composable.noRippleClick import com.zhangke.framework.utils.toPx import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.blog.BlogPoll import com.zhangke.fread.status.model.BlogTranslationUiState import com.zhangke.fread.status.model.HashtagInStatus import com.zhangke.fread.status.model.Mention import com.zhangke.fread.status.model.isRss import com.zhangke.fread.status.richtext.RichText import com.zhangke.fread.status.richtext.buildRichText import com.zhangke.fread.status.ui.common.BlogTranslateLabel import com.zhangke.fread.status.ui.embed.BlogEmbedsUi import com.zhangke.fread.status.ui.image.OnBlogMediaClick import com.zhangke.fread.status.ui.label.StatusBottomEditedLabel import com.zhangke.fread.status.ui.label.StatusBottomInteractionLabel import com.zhangke.fread.status.ui.label.StatusBottomTimeLabel import com.zhangke.fread.status.ui.media.BlogMedias import com.zhangke.fread.status.ui.poll.BlogPoll import com.zhangke.fread.status.ui.richtext.FreadRichText import com.zhangke.fread.status.ui.style.LocalStatusUiConfig import com.zhangke.fread.status.ui.style.StatusStyle import com.zhangke.fread.status.ui.style.StatusStyle.ContentStyle /** * 博客正文部分,仅包含内容,投票,媒体,链接预览卡片。 */ @Composable fun BlogContent( modifier: Modifier, blog: Blog, isOwner: Boolean?, style: StatusStyle, indexOfFeeds: Int, sharedElementId: String? = null, onBlogClick: (Blog) -> Unit, onMediaClick: OnBlogMediaClick = {}, blogTranslationState: BlogTranslationUiState = BlogTranslationUiState.DEFAULT, onVoted: (List) -> Unit = {}, onHashtagInStatusClick: (HashtagInStatus) -> Unit = {}, onMaybeHashtagClick: (String) -> Unit, onBoostedClick: ((String) -> Unit)? = null, onFavouritedClick: ((String) -> Unit)? = null, onUrlClick: (url: String) -> Unit = {}, onMentionClick: (Mention) -> Unit = {}, onMentionDidClick: (String) -> Unit = {}, onShowOriginalClick: () -> Unit, onUnavailableQuoteClick: (String) -> Unit, detailModel: Boolean = false, editedTime: String? = null, ) { Column( modifier = modifier, ) { BlogTranslateLabel( modifier = Modifier, style = style, blogTranslationState = blogTranslationState, onShowOriginalClick = onShowOriginalClick, ) if (detailModel) { SelectionContainer { Column { BlogTextContentSection( blog = blog, blogTranslationState = blogTranslationState, style = style.contentStyle, onHashtagInStatusClick = onHashtagInStatusClick, onMentionClick = onMentionClick, onMentionDidClick = onMentionDidClick, onMaybeHashtagClick = onMaybeHashtagClick, onUrlClick = onUrlClick, ) } } } else { BlogTextContentSection( blog = blog, blogTranslationState = blogTranslationState, style = style.contentStyle, onHashtagInStatusClick = onHashtagInStatusClick, onMentionClick = onMentionClick, onMentionDidClick = onMentionDidClick, onUrlClick = onUrlClick, onMaybeHashtagClick = onMaybeHashtagClick, ) } val sensitive = blog.sensitive || blog.sensitiveByFilter if (blog.poll != null) { BlogPoll( modifier = Modifier .padding(top = style.contentStyle.contentVerticalSpacing) .fillMaxWidth(), poll = blog.poll!!, isSelf = isOwner, blogTranslationState = blogTranslationState, onVoted = { onVoted(it) }, ) } else if (blog.mediaList.isNotEmpty()) { BlogMedias( modifier = Modifier .padding(top = style.contentStyle.contentVerticalSpacing) .fillMaxWidth(), mediaList = blog.mediaList, sharedElementId = sharedElementId ?: blog.id, blogTranslationState = blogTranslationState, indexInList = indexOfFeeds, sensitive = sensitive, onMediaClick = onMediaClick, ) } else if (blog.embeds.isNotEmpty()) { BlogEmbedsUi( modifier = Modifier .padding(top = style.contentStyle.contentVerticalSpacing) .fillMaxWidth(), embeds = blog.embeds, style = style, onContentClick = onBlogClick, onUrlClick = onUrlClick, onUnavailableQuoteClick = onUnavailableQuoteClick, ) } if (detailModel) { StatusBottomTimeLabel( modifier = Modifier .fillMaxWidth() .padding(top = style.contentStyle.contentVerticalSpacing), blog = blog, specificTime = blog.formattedCreateAt, style = style, onUrlClick = onUrlClick, ) if (!editedTime.isNullOrEmpty()) { StatusBottomEditedLabel( modifier = Modifier .padding(top = style.contentStyle.contentVerticalSpacing), editedAt = editedTime, style = style, ) } if (blog.like.likedCount != null && blog.forward.forwardCount != null) { StatusBottomInteractionLabel( modifier = Modifier .fillMaxWidth() .padding(top = style.contentStyle.contentVerticalSpacing), boostedCount = blog.forward.forwardCount!!, favouritedCount = blog.like.likedCount!!, style = style, onBoostedClick = { onBoostedClick?.invoke(blog.id) }, onFavouritedClick = { onFavouritedClick?.invoke(blog.id) }, ) } } } } @Composable fun BlogTextContentSection( blog: Blog, style: ContentStyle, blogTranslationState: BlogTranslationUiState? = null, onHashtagInStatusClick: (HashtagInStatus) -> Unit = {}, onMaybeHashtagClick: (String) -> Unit = {}, onMentionClick: (Mention) -> Unit = {}, onMentionDidClick: (String) -> Unit = {}, onUrlClick: (url: String) -> Unit = {}, ) { val contentMaxLine: Int = if (blog.platform.protocol.isRss) { style.maxLine } else { Int.MAX_VALUE } val showWarning = blog.spoilerText.isNotEmpty() || blog.sensitiveByFilter val spoilerText = blog.spoilerText if (showWarning) { val statusConfig = LocalStatusUiConfig.current var hideContent by rememberSaveable( showWarning, spoilerText, statusConfig.alwaysShowSensitiveContent, ) { mutableStateOf(!statusConfig.alwaysShowSensitiveContent) } val humanizedSpoilerText = if (blogTranslationState?.showingTranslation == true) { blogTranslationState.blogTranslation!!.getHumanizedSpoilerText(blog) } else if (blog.spoilerText.isNotEmpty()) { blog.humanizedSpoilerText } else { val text = org.jetbrains.compose.resources.stringResource( LocalizedString.statusUiSensitiveByFilter, blog.filtered!!.first().title, ) remember { buildRichText(document = text, type = blog.richTextType) } } SpoilerText( modifier = Modifier, hideContent = hideContent, spoilerText = humanizedSpoilerText, fontSize = style.contentSize, onShowContent = { hideContent = false }, onHideContent = { hideContent = true }, onHashtagInStatusClick = onHashtagInStatusClick, onMentionClick = onMentionClick, onMentionDidClick = onMentionDidClick, onUrlClick = onUrlClick, ) if (blog.content.isNotEmpty()) { AnimatedVisibility( visible = !hideContent, enter = expandVertically(), exit = shrinkVertically(), ) { val humanizedContent = if (blogTranslationState?.showingTranslation == true) { blogTranslationState.blogTranslation!!.getHumanizedContent(blog) } else { blog.humanizedContent } FreadRichText( modifier = Modifier .fillMaxWidth() .padding(top = 4.dp) .wrapContentHeight(), richText = humanizedContent, maxLines = contentMaxLine, onMentionClick = onMentionClick, onMentionDidClick = onMentionDidClick, onHashtagClick = onHashtagInStatusClick, onMaybeHashtagClick = onMaybeHashtagClick, onUrlClick = onUrlClick, fontSize = style.contentSize, ) } } } else { if (!blog.title.isNullOrEmpty()) { Text( modifier = Modifier, text = blog.title!!, fontWeight = FontWeight.Bold, fontSize = style.titleSize, overflow = TextOverflow.Ellipsis, maxLines = 1, style = MaterialTheme.typography.titleMedium, ) } if (!blog.description.isNullOrEmpty()) { val topPadding = if (blog.title.isNullOrEmpty()) { style.contentVerticalSpacing } else { style.contentVerticalSpacing / 2 } FreadRichText( modifier = Modifier .padding(top = topPadding), richText = blog.humanizedDescription, maxLines = contentMaxLine, onMentionClick = onMentionClick, onMentionDidClick = onMentionDidClick, onHashtagClick = onHashtagInStatusClick, onMaybeHashtagClick = onMaybeHashtagClick, onUrlClick = onUrlClick, fontSize = style.contentSize, ) } if ( blog.title.isNullOrEmpty() && blog.description.isNullOrEmpty() && blog.content.isNotEmpty() ) { val humanizedContent = if (blogTranslationState?.showingTranslation == true) { blogTranslationState.blogTranslation!!.getHumanizedContent(blog) } else { blog.humanizedContent } FreadRichText( modifier = Modifier .fillMaxWidth() .wrapContentHeight(), richText = humanizedContent, maxLines = contentMaxLine, onMentionClick = onMentionClick, onMentionDidClick = onMentionDidClick, onHashtagClick = onHashtagInStatusClick, onMaybeHashtagClick = onMaybeHashtagClick, fontSize = style.contentSize, onUrlClick = onUrlClick, ) } } } @Composable private fun SpoilerText( modifier: Modifier, hideContent: Boolean, spoilerText: RichText, fontSize: TextUnit, onShowContent: () -> Unit, onHideContent: () -> Unit, onUrlClick: (url: String) -> Unit, onHashtagInStatusClick: (HashtagInStatus) -> Unit, onMentionClick: (Mention) -> Unit, onMentionDidClick: (String) -> Unit, ) { Box( modifier = modifier .fillMaxWidth() .wrapContentHeight() .drawSpoilerBackground() .noRippleClick { if (hideContent) { onShowContent() } else { onHideContent() } } ) { FreadRichText( modifier = Modifier .fillMaxWidth() .wrapContentHeight() .padding(start = 16.dp, top = 22.dp, end = 16.dp, bottom = 6.dp), richText = spoilerText, color = inverseOnSurfaceDark, onMentionClick = onMentionClick, onMentionDidClick = onMentionDidClick, onHashtagClick = onHashtagInStatusClick, onUrlClick = onUrlClick, fontSize = fontSize, ) } } @Composable fun Modifier.drawSpoilerBackground(): Modifier { val edgeColor = Color(0xFFFFB84D) val backgroundColor = Color(0xFFFFEED3) val edgeWidth = 8.dp.toPx() val cornerRadiiPx = 6.dp.toPx() val cornerRadius = CornerRadius(cornerRadiiPx, cornerRadiiPx) return this.drawBehind { val canvasWidth = size.width val canvasHeight = size.height val startEdge = Path().apply { addRoundRect( RoundRect( rect = Rect( offset = Offset.Zero, Size(width = edgeWidth, height = canvasHeight), ), topLeft = cornerRadius, bottomLeft = cornerRadius, ) ) } val endEdge = Path().apply { addRoundRect( RoundRect( rect = Rect( offset = Offset(x = canvasWidth - edgeWidth, y = 0F), Size(width = edgeWidth, height = canvasHeight), ), topRight = cornerRadius, bottomRight = cornerRadius, ) ) } drawPath(startEdge, edgeColor) drawPath(endEdge, edgeColor) drawRect( color = backgroundColor, topLeft = Offset(x = edgeWidth, y = 0F), size = size.copy(width = canvasWidth - edgeWidth * 2), ) var pointStartOffset = 18.dp.toPx() val pointRadii = 1.5.dp.toPx() repeat(3) { drawCircle( color = Color.Black.copy(alpha = 0.8F), radius = pointRadii, center = Offset(x = pointStartOffset, y = 14.dp.toPx()) ) pointStartOffset += pointRadii + 6.dp.toPx() } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/BlogDivider.kt ================================================ package com.zhangke.fread.status.ui import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun BlogDivider( modifier: Modifier = Modifier, ) { HorizontalDivider( modifier = modifier, thickness = 0.5.dp ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/BlogUi.kt ================================================ package com.zhangke.fread.status.ui import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding 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.Modifier import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.unit.dp import com.zhangke.fread.common.handler.LocalTextHandler import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.blog.BlogPoll import com.zhangke.fread.status.model.BlogTranslationUiState import com.zhangke.fread.status.model.HashtagInStatus import com.zhangke.fread.status.model.Mention import com.zhangke.fread.status.model.StatusActionType import com.zhangke.fread.status.ui.action.StatusBottomInteractionPanel import com.zhangke.fread.status.ui.image.OnBlogMediaClick import com.zhangke.fread.status.ui.style.StatusStyle import com.zhangke.fread.status.ui.threads.ThreadsType import com.zhangke.fread.status.ui.threads.threads @Composable fun BlogUi( modifier: Modifier, blog: Blog, blogTranslationState: BlogTranslationUiState, isOwner: Boolean?, logged: Boolean?, indexInList: Int, sharedElementId: String? = null, style: StatusStyle, topLabels: List<@Composable () -> Unit>, reblogAuthor: BlogAuthor? = null, onInteractive: (StatusActionType, Blog) -> Unit, onMediaClick: OnBlogMediaClick, onUserInfoClick: (BlogAuthor) -> Unit, onVoted: (List) -> Unit, onHashtagInStatusClick: (HashtagInStatus) -> Unit, onMaybeHashtagClick: (String) -> Unit, onUrlClick: (url: String) -> Unit, onMentionClick: (Mention) -> Unit, onMentionDidClick: (String) -> Unit, onShowOriginalClick: () -> Unit, onBlogClick: (Blog) -> Unit, onTranslateClick: () -> Unit, continueThreadLabelHeight: Int? = null, onBoostedClick: ((String) -> Unit)? = null, onFavouritedClick: ((String) -> Unit)? = null, onFollowClick: ((BlogAuthor) -> Unit)? = null, detailModel: Boolean = false, showDivider: Boolean = true, showBottomPanel: Boolean = true, showMoreOperationIcon: Boolean = true, threadsType: ThreadsType = ThreadsType.NONE, onOpenBlogWithOtherAccountClick: (Blog) -> Unit = {}, onUnavailableQuoteClick: (String) -> Unit = {}, ) { val textHandler = LocalTextHandler.current var infoToTopSpacing: Float? by remember { mutableStateOf(null) } Column( modifier = modifier .fillMaxWidth() .threads( threadsType = threadsType, infoToTopSpacing = infoToTopSpacing, style = style, continueThreadLabelHeight = continueThreadLabelHeight, ) ) { if (topLabels.isNotEmpty()) { Spacer(modifier = Modifier.height(style.containerTopPadding / 2)) } topLabels.forEachIndexed { index, composable -> composable() Spacer(modifier = Modifier.height(style.infolineToTopLabelPadding)) } val infoTopPadding = if (topLabels.isEmpty()) { style.containerTopPadding } else { 0.dp } StatusInfoLine( modifier = Modifier .padding(top = infoTopPadding) .fillMaxWidth() .let { if (threadsType != ThreadsType.NONE && threadsType != ThreadsType.UNSPECIFIED) { it.onGloballyPositioned { coordinates -> if (coordinates.positionInParent().y != infoToTopSpacing) { infoToTopSpacing = coordinates.positionInParent().y } } } else { it } }, blog = blog, blogTranslationState = blogTranslationState, displayTime = blog.formattingDisplayTime.formattedTime(), visibility = blog.visibility, isOwner = isOwner, showMoreOperationIcon = showMoreOperationIcon, allowToShowFollowButton = isOwner == false && detailModel, onInteractive = onInteractive, onUserInfoClick = onUserInfoClick, onUrlClick = onUrlClick, onFollowClick = onFollowClick, style = style, reblogAuthor = reblogAuthor, editedAt = blog.editedAt?.instant, onTranslateClick = onTranslateClick, onOpenBlogWithOtherAccountClick = onOpenBlogWithOtherAccountClick, showOpenBlogWithOtherAccountBtn = true, ) BlogContent( modifier = Modifier .fillMaxWidth() .padding( start = style.containerStartPadding + style.contentStyle.startPadding, top = style.contentStyle.contentVerticalSpacing, end = style.containerEndPadding, ), blog = blog, isOwner = isOwner, blogTranslationState = blogTranslationState, detailModel = detailModel, indexOfFeeds = indexInList, sharedElementId = sharedElementId, style = style, onMediaClick = onMediaClick, onVoted = onVoted, onUrlClick = onUrlClick, onBoostedClick = onBoostedClick, onFavouritedClick = onFavouritedClick, editedTime = blog.formattedEditAt, onHashtagInStatusClick = onHashtagInStatusClick, onMentionClick = onMentionClick, onMentionDidClick = onMentionDidClick, onShowOriginalClick = onShowOriginalClick, onBlogClick = onBlogClick, onMaybeHashtagClick = onMaybeHashtagClick, onUnavailableQuoteClick = onUnavailableQuoteClick, ) if (showBottomPanel) { StatusBottomInteractionPanel( modifier = Modifier .fillMaxWidth() .padding( start = style.containerStartPadding / 2 + style.bottomPanelStyle.startPadding, top = style.contentStyle.contentVerticalSpacing, end = style.containerEndPadding / 2 ), style = style, blog = blog, logged = logged, onInteractive = { type, blog -> if (type == StatusActionType.SHARE) { textHandler.shareUrl(blog.link, blog.content) return@StatusBottomInteractionPanel } onInteractive(type, blog) }, ) } Spacer( modifier = Modifier .fillMaxWidth() .height(style.containerBottomPadding) ) if (showDivider) { BlogDivider() } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/ComposedStatusInteraction.kt ================================================ package com.zhangke.fread.status.ui import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.blog.BlogPoll import com.zhangke.fread.status.model.Hashtag import com.zhangke.fread.status.model.HashtagInStatus import com.zhangke.fread.status.model.Mention import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusActionType import com.zhangke.fread.status.model.StatusProviderProtocol import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.platform.BlogPlatform interface ComposedStatusInteraction { fun onStatusInteractive(status: StatusUiState, type: StatusActionType) fun onUserInfoClick(locator: PlatformLocator, blogAuthor: BlogAuthor) fun onVoted(status: StatusUiState, blogPollOptions: List) fun onHashtagInStatusClick(locator: PlatformLocator, hashtagInStatus: HashtagInStatus) fun onHashtagClick(locator: PlatformLocator, tag: Hashtag) fun onMaybeHashtagClick( locator: PlatformLocator, protocol: StatusProviderProtocol, hashtag: String ) fun onMentionClick(locator: PlatformLocator, mention: Mention) fun onMentionClick(locator: PlatformLocator, did: String, protocol: StatusProviderProtocol) fun onStatusClick(status: StatusUiState) fun onBlogClick(locator: PlatformLocator, blog: Blog) fun onBlogIdClick(locator: PlatformLocator, platform: BlogPlatform, blogId: String) fun onBlockClick(locator: PlatformLocator, blog: Blog) fun onFollowClick(locator: PlatformLocator, target: BlogAuthor) fun onUnfollowClick(locator: PlatformLocator, target: BlogAuthor) fun onBoostedClick(locator: PlatformLocator, status: StatusUiState) fun onFavouritedClick(locator: PlatformLocator, status: StatusUiState) fun onTranslateClick(locator: PlatformLocator, status: StatusUiState) fun onShowOriginalClick(status: StatusUiState) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/StatusInfoLine.kt ================================================ @file:OptIn(ExperimentalTime::class) package com.zhangke.fread.status.ui import androidx.compose.foundation.layout.Column 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.layout.size import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.noRippleClick import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.model.BlogTranslationUiState import com.zhangke.fread.status.model.Relationships import com.zhangke.fread.status.model.StatusActionType import com.zhangke.fread.status.model.StatusVisibility import com.zhangke.fread.status.ui.action.StatusMoreInteractionIcon import com.zhangke.fread.status.ui.richtext.FreadRichText import com.zhangke.fread.status.ui.style.StatusStyle import kotlinx.datetime.Instant import org.jetbrains.compose.resources.stringResource import kotlin.time.ExperimentalTime /** * Status 头部信息行,主要包括头像, * 用户名,Handle,时间,更多按钮等。 */ @Composable fun StatusInfoLine( modifier: Modifier, blog: Blog, blogTranslationState: BlogTranslationUiState, isOwner: Boolean?, displayTime: String, style: StatusStyle, visibility: StatusVisibility, allowToShowFollowButton: Boolean, showMoreOperationIcon: Boolean = true, onUrlClick: (url: String) -> Unit = {}, onInteractive: (StatusActionType, Blog) -> Unit = { _, _ -> }, onUserInfoClick: ((BlogAuthor) -> Unit)? = null, onFollowClick: ((BlogAuthor) -> Unit)? = null, onTranslateClick: () -> Unit = {}, reblogAuthor: BlogAuthor? = null, editedAt: Instant? = null, onOpenBlogWithOtherAccountClick: (Blog) -> Unit = {}, showOpenBlogWithOtherAccountBtn: Boolean = true, ) { val blogAuthor = blog.author Row( modifier = modifier.padding(start = style.containerStartPadding), ) { BlogAuthorAvatar( modifier = Modifier .size(style.infoLineStyle.avatarSize), onClick = { onUserInfoClick?.invoke(blogAuthor) }, reblogAvatar = reblogAuthor?.avatar, authorAvatar = blogAuthor.avatar, ) Column( modifier = Modifier .weight(1F) .padding(start = style.infoLineStyle.nameToAvatarSpacing, end = 6.dp), ) { FreadRichText( modifier = Modifier .wrapContentWidth(unbounded = false) .noRippleClick(enabled = onUserInfoClick != null) { onUserInfoClick?.invoke(blogAuthor) }, maxLines = 1, overflow = TextOverflow.Ellipsis, richText = blogAuthor.humanizedName, onUrlClick = onUrlClick, fontSize = style.infoLineStyle.nameSize, ) Row( modifier = Modifier .fillMaxWidth() .padding(top = 1.dp), verticalAlignment = Alignment.CenterVertically, ) { if (visibility == StatusVisibility.PRIVATE || visibility == StatusVisibility.UNLISTED || visibility == StatusVisibility.DIRECT ) { Icon( modifier = Modifier .padding(end = 4.dp) .size(14.dp), imageVector = if (visibility == StatusVisibility.UNLISTED) { Icons.Default.LockOpen } else { Icons.Default.Lock }, contentDescription = null, ) } val fontColor = style.secondaryFontColor if (editedAt != null) { Text( modifier = Modifier .padding(end = 4.dp), text = stringResource(LocalizedString.statusUiInfoLabelEdited), style = style.infoLineStyle.descStyle, maxLines = 1, color = fontColor, overflow = TextOverflow.Ellipsis, ) } Text( modifier = Modifier, text = displayTime, style = style.infoLineStyle.descStyle, maxLines = 1, color = fontColor, overflow = TextOverflow.Ellipsis, ) Text( modifier = Modifier.padding(start = 4.dp), text = blogAuthor.prettyHandle, style = style.infoLineStyle.descStyle, maxLines = 1, color = fontColor, overflow = TextOverflow.Ellipsis, ) } } val relationships = blogAuthor.relationships if (allowToShowFollowButton && (relationships?.following == false)) { FollowButton( modifier = Modifier .align(Alignment.Top) .heightIn(min = 20.dp) .padding(end = 4.dp), relationships = relationships, contentPadding = PaddingValues(horizontal = 6.dp, vertical = 4.dp), onFollowClick = { onFollowClick?.invoke(blogAuthor) }, ) } if (showMoreOperationIcon) { val moreIconAlign = if (allowToShowFollowButton) { Alignment.CenterVertically } else { Alignment.Top } StatusMoreInteractionIcon( modifier = Modifier .align(moreIconAlign) .padding(end = style.containerEndPadding / 2), blog = blog, isOwner = isOwner, blogTranslationState = blogTranslationState, style = style, onActionClick = onInteractive, onTranslateClick = onTranslateClick, showOpenBlogWithOtherAccountBtn = showOpenBlogWithOtherAccountBtn, onOpenBlogWithOtherAccountClick = onOpenBlogWithOtherAccountClick, ) } } } @Composable private fun FollowButton( modifier: Modifier, relationships: Relationships, contentPadding: PaddingValues, onFollowClick: () -> Unit, ) { Button( modifier = modifier, onClick = onFollowClick, contentPadding = contentPadding, ) { Text( text = if (relationships.followedBy) { stringResource(LocalizedString.statusUiUserDetailRelationshipFollowBack) } else { stringResource(LocalizedString.statusUiUserDetailRelationshipNotFollow) }, ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/StatusPlaceHolder.kt ================================================ package com.zhangke.fread.status.ui 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.LocalContentPadding import com.zhangke.framework.composable.freadPlaceholder import com.zhangke.fread.status.ui.style.StatusInfoStyleDefaults @Composable fun StatusPlaceHolder( modifier: Modifier = Modifier, ) { Row(modifier = modifier) { Spacer(Modifier.width(16.dp)) Box( modifier = Modifier .padding(top = 8.dp) .clip(CircleShape) .size(StatusInfoStyleDefaults.avatarSize) .freadPlaceholder(true), ) Spacer(Modifier.width(8.dp)) Column(Modifier.weight(1f)) { Spacer(Modifier.height(4.dp)) Box( modifier = Modifier .size(100.dp, 16.dp) .freadPlaceholder(true), ) Spacer(Modifier.height(2.dp)) Box( modifier = Modifier .size(200.dp, 12.dp) .freadPlaceholder(true), ) Spacer(Modifier.height(4.dp)) repeat(3) { Box( modifier = Modifier .fillMaxWidth() .padding(top = 4.dp) .height(14.dp) .freadPlaceholder(true), ) } Spacer(Modifier.height(8.dp)) } Spacer(Modifier.width(16.dp)) } } @Composable fun StatusListPlaceholder( modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize().padding(LocalContentPadding.current)) { Column( modifier = Modifier.fillMaxSize(), ) { repeat(8) { StatusPlaceHolder(modifier = Modifier.fillMaxWidth()) Box(modifier = Modifier.height(6.dp)) } } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/StatusUi.kt ================================================ package com.zhangke.fread.status.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.common.browser.launchWebTabInApp import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.BlogFiltered import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.model.StatusVisibility import com.zhangke.fread.status.status.model.Status import com.zhangke.fread.status.ui.image.OnBlogMediaClick import com.zhangke.fread.status.ui.label.ContinueThread import com.zhangke.fread.status.ui.label.ReblogTopLabel import com.zhangke.fread.status.ui.label.StatusMentionOnlyLabel import com.zhangke.fread.status.ui.label.StatusPinnedLabel import com.zhangke.fread.status.ui.style.LocalStatusUiConfig import com.zhangke.fread.status.ui.style.StatusStyle import com.zhangke.fread.status.ui.threads.ThreadsType @Composable fun StatusUi( modifier: Modifier = Modifier, status: StatusUiState, indexInList: Int, sharedElementId: String? = null, style: StatusStyle = LocalStatusUiConfig.current.contentStyle, onMediaClick: OnBlogMediaClick, composedStatusInteraction: ComposedStatusInteraction, detailModel: Boolean = false, showDivider: Boolean = true, threadsType: ThreadsType = ThreadsType.UNSPECIFIED, onOpenBlogWithOtherAccountClick: (StatusUiState) -> Unit = {}, ) { val browserLauncher = LocalActivityBrowserLauncher.current if (status.status.intrinsicBlog.filtered?.firstOrNull()?.action == BlogFiltered.FilterAction.HIDE) { Box(modifier = modifier.size(1.dp)) return } val fixedThreadType = if (threadsType == ThreadsType.UNSPECIFIED && status.status.intrinsicBlog.isReply) { ThreadsType.CONTINUED_THREAD } else { threadsType } val rawStatus = status.status val resolvedSharedElementId = sharedElementId ?: rawStatus.id var continueThreadHeight: Int? by remember { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() Box(modifier = modifier) { BlogUi( modifier = Modifier, blog = rawStatus.intrinsicBlog, isOwner = status.isOwner, logged = status.logged, blogTranslationState = status.blogTranslationState, continueThreadLabelHeight = continueThreadHeight, topLabels = getStatusTopLabel( isReblog = rawStatus is Status.Reblog, pinned = rawStatus.intrinsicBlog.pinned, isReply = rawStatus.intrinsicBlog.isReply, author = rawStatus.triggerAuthor, style = style, threadsType = fixedThreadType, mentionOnly = rawStatus.intrinsicBlog.visibility == StatusVisibility.DIRECT, onUserInfoClick = { composedStatusInteraction.onUserInfoClick(status.locator, it) }, onContinueThreadHeightChanged = { continueThreadHeight = it } ), indexInList = indexInList, sharedElementId = resolvedSharedElementId, threadsType = fixedThreadType, detailModel = detailModel, style = if (detailModel) style else style.contentIndentStyle(), onInteractive = { type, _ -> composedStatusInteraction.onStatusInteractive(status, type) }, showDivider = showDivider && threadsType != ThreadsType.ANCESTOR && threadsType != ThreadsType.FIRST_ANCESTOR, onMediaClick = onMediaClick, onUserInfoClick = { composedStatusInteraction.onUserInfoClick(status.locator, it) }, onVoted = { options -> composedStatusInteraction.onVoted(status, options) }, onHashtagInStatusClick = { composedStatusInteraction.onHashtagInStatusClick(status.locator, it) }, onMentionClick = { composedStatusInteraction.onMentionClick(status.locator, it) }, onMentionDidClick = { composedStatusInteraction.onMentionClick( locator = status.locator, did = it, protocol = status.status.platform.protocol, ) }, onFollowClick = { composedStatusInteraction.onFollowClick(status.locator, it) }, onUrlClick = { browserLauncher.launchWebTabInApp(coroutineScope, it, status.locator) }, onBoostedClick = { composedStatusInteraction.onBoostedClick(status.locator, status) }, onFavouritedClick = { composedStatusInteraction.onFavouritedClick(status.locator, status) }, onShowOriginalClick = { composedStatusInteraction.onShowOriginalClick(status) }, onTranslateClick = { composedStatusInteraction.onTranslateClick(status.locator, status) }, onBlogClick = { composedStatusInteraction.onBlockClick(status.locator, it) }, onMaybeHashtagClick = { composedStatusInteraction.onMaybeHashtagClick( locator = status.locator, protocol = status.status.platform.protocol, hashtag = it, ) }, onOpenBlogWithOtherAccountClick = { onOpenBlogWithOtherAccountClick(status) }, onUnavailableQuoteClick = { composedStatusInteraction.onBlogIdClick( locator = status.locator, platform = status.status.platform, blogId = rawStatus.id, ) }, ) } } fun getStatusTopLabel( style: StatusStyle, threadsType: ThreadsType, isReblog: Boolean, pinned: Boolean, isReply: Boolean, author: BlogAuthor, mentionOnly: Boolean, onUserInfoClick: (blogAuthor: BlogAuthor) -> Unit, onContinueThreadHeightChanged: (height: Int) -> Unit, ): List<@Composable () -> Unit> { val labels = mutableListOf<@Composable () -> Unit>() if (isReblog) { labels += { ReblogTopLabel( author = author, style = style, onAuthorClick = onUserInfoClick, ) } } else if (threadsType == ThreadsType.CONTINUED_THREAD && isReply) { labels += { ContinueThread(style = style, onHeightChanged = onContinueThreadHeightChanged) } } if (mentionOnly) { labels += { StatusMentionOnlyLabel( modifier = Modifier, style = style, ) } } if (pinned) { labels += { StatusPinnedLabel( style = style, ) } } return labels } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/action/ModalDropdownMenuItem.kt ================================================ package com.zhangke.fread.status.ui.action import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MenuDefaults import androidx.compose.material3.MenuItemColors import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector @Composable fun ModalDropdownMenuItem( imageVector: ImageVector, text: String, colors: MenuItemColors = MenuDefaults.itemColors(), onClick: () -> Unit, ) { DropdownMenuItem( text = { Text(text = text) }, colors = colors, leadingIcon = { Icon( imageVector = imageVector, contentDescription = text, ) }, onClick = onClick, ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/action/StatusActions.kt ================================================ package com.zhangke.fread.status.ui.action import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CloudQueue import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.PersonSearch import androidx.compose.runtime.Composable import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.stringResource @Composable fun DropDownCopyLinkItem( onClick: () -> Unit, ) { ModalDropdownMenuItem( text = stringResource(LocalizedString.statusUiInteractionCopyUrl), imageVector = Icons.Default.ContentCopy, onClick = onClick, ) } @Composable fun DropDownOpenInBrowserItem(onClick: () -> Unit) { ModalDropdownMenuItem( text = stringResource(LocalizedString.statusUiInteractionOpenInBrowser), imageVector = Icons.Default.Language, onClick = onClick, ) } @Composable fun DropDownOpenOriginalInstanceItem(onClick: () -> Unit) { ModalDropdownMenuItem( text = stringResource(LocalizedString.statusUiInteractionOpenOriginalInstance), imageVector = Icons.Default.CloudQueue, onClick = onClick, ) } @Composable fun DropDownOpenStatusByOtherAccountItem(onClick: () -> Unit) { ModalDropdownMenuItem( text = stringResource(LocalizedString.statusUiInteractionOpenBlogByOtherAccount), imageVector = Icons.Default.PersonSearch, onClick = onClick, ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/action/StatusBottomInteractionPanel.kt ================================================ package com.zhangke.fread.status.ui.action import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.rememberTransientModalBottomSheetState import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.model.StatusActionType import com.zhangke.fread.status.ui.style.StatusStyle import kotlinx.coroutines.launch @Composable fun StatusBottomInteractionPanel( modifier: Modifier = Modifier, style: StatusStyle, blog: Blog, logged: Boolean?, onInteractive: (StatusActionType, Blog) -> Unit, ) { Row( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { ForwardActionIcon( modifier = Modifier, blog = blog, style = style, logged = logged, onInteractive = onInteractive, ) Spacer(modifier = Modifier.weight(1F)) StatusActionIcon( modifier = Modifier, imageVector = replyIcon(), enabled = logged == true && blog.reply.support, style = style, contentDescription = replyAlt(), text = blog.reply.repliesCount?.countToLabel(), highLight = false, onClick = { onInteractive(StatusActionType.REPLY, blog) }, ) Spacer(modifier = Modifier.weight(1F)) StatusActionIcon( modifier = Modifier, imageVector = likeIcon(blog.like.liked == true), enabled = logged == true && blog.like.support, style = style, contentDescription = likeAlt(), text = blog.like.likedCount?.countToLabel(), highLight = blog.like.liked == true, onClick = { onInteractive(StatusActionType.LIKE, blog) }, ) if (blog.bookmark.support) { Spacer(modifier = Modifier.weight(1F)) StatusActionIcon( modifier = Modifier, imageVector = bookmarkIcon(blog.bookmark.bookmarked == true), enabled = logged == true, style = style, contentDescription = bookmarkAlt(blog.bookmark.bookmarked == true), text = null, highLight = blog.bookmark.bookmarked == true, onClick = { onInteractive(StatusActionType.BOOKMARK, blog) }, ) } Spacer(modifier = Modifier.weight(1F)) StatusActionIcon( modifier = Modifier, imageVector = shareIcon(), enabled = true, style = style, contentDescription = shareAlt(), text = null, highLight = false, contentAlignment = Alignment.CenterEnd, onClick = { onInteractive(StatusActionType.SHARE, blog) }, ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ForwardActionIcon( modifier: Modifier, blog: Blog, style: StatusStyle, logged: Boolean?, onInteractive: (StatusActionType, Blog) -> Unit, ) { var showForwardDialog by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() val sheetState = rememberTransientModalBottomSheetState() val highlight = blog.forward.forward == true StatusActionIcon( modifier = modifier, imageVector = forwardIcon(), enabled = logged == true && (blog.forward.support || blog.quote.support), style = style, contentDescription = forwardAlt(), text = blog.forward.forwardCount?.countToLabel(), highLight = highlight, contentAlignment = Alignment.CenterStart, onClick = { if (!blog.quote.support) { onInteractive(StatusActionType.FORWARD, blog) } else { showForwardDialog = true } }, ) if (showForwardDialog) { ModalBottomSheet( sheetState = sheetState, onDismissRequest = { showForwardDialog = false }, ) { Column( modifier = Modifier.fillMaxWidth(), ) { val contentColor = if (highlight) { MaterialTheme.colorScheme.tertiary } else { LocalContentColor.current } Row( modifier = Modifier.fillMaxWidth() .clickable { coroutineScope.launch { sheetState.hide() showForwardDialog = false onInteractive(StatusActionType.FORWARD, blog) } } .padding(vertical = 16.dp, horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( modifier = Modifier.size(18.dp), imageVector = forwardIcon(), contentDescription = forwardAlt(), tint = contentColor, ) Spacer(modifier = Modifier.size(8.dp)) Text( modifier = Modifier.padding(start = 2.dp), text = if (highlight) { unforwardAlt() } else { forwardAlt() }, maxLines = 1, color = contentColor, ) } val quoteContentColor = if (blog.quote.enabled) { IconButtonDefaults.iconButtonColors().contentColor } else { IconButtonDefaults.iconButtonColors().disabledContentColor } CompositionLocalProvider(LocalContentColor provides quoteContentColor) { Row( modifier = Modifier.fillMaxWidth() .clickable( enabled = blog.quote.enabled, role = Role.Button, ) { coroutineScope.launch { sheetState.hide() showForwardDialog = false onInteractive(StatusActionType.QUOTE, blog) } } .padding(vertical = 16.dp, horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( modifier = Modifier.size(18.dp), imageVector = quoteInLeftIcon(), contentDescription = quoteAlt(), ) Spacer(modifier = Modifier.size(8.dp)) Text( modifier = Modifier.padding(start = 2.dp), text = quoteAlt(), maxLines = 1, ) } } Spacer(modifier = Modifier.height(16.dp)) } } } } @Composable private fun StatusActionIcon( modifier: Modifier = Modifier, imageVector: ImageVector, enabled: Boolean, contentDescription: String, style: StatusStyle, text: String? = null, highLight: Boolean, onClick: () -> Unit, contentAlignment: Alignment = Alignment.Center, ) { StatusIconButton( modifier = modifier.height(style.bottomPanelStyle.iconSize), onClick = onClick, enabled = enabled, contentAlignment = contentAlignment, ) { Row( modifier.padding(horizontal = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { val contentColor = if (highLight) { MaterialTheme.colorScheme.tertiary } else { LocalContentColor.current } Icon( modifier = Modifier.size(18.dp), imageVector = imageVector, contentDescription = contentDescription, tint = contentColor, ) if (text != null) { Text( modifier = Modifier.padding(start = 2.dp), text = text, maxLines = 1, color = contentColor, style = MaterialTheme.typography.labelMedium, ) } } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/action/StatusIconButton.kt ================================================ package com.zhangke.fread.status.ui.action import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.sizeIn import androidx.compose.material3.IconButtonColors import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.coerceAtLeast import androidx.compose.ui.unit.dp @Composable fun StatusIconButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, contentAlignment: Alignment = Alignment.Center, content: @Composable () -> Unit, ) { val size = LocalMinimumInteractiveComponentSize.current.coerceAtLeast(0.dp) Box( modifier = modifier .sizeIn(minWidth = size, minHeight = size) .background(color = if (enabled) colors.containerColor else colors.disabledContainerColor) .clickable( onClick = onClick, enabled = enabled, role = Role.Button, interactionSource = interactionSource, indication = ripple( bounded = false, radius = 20.dp, ) ), contentAlignment = contentAlignment, ) { val contentColor = if (enabled) colors.contentColor else colors.disabledContentColor CompositionLocalProvider(LocalContentColor provides contentColor, content = content) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/action/StatusInteractiveExts.kt ================================================ package com.zhangke.fread.status.ui.action import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.BookmarkBorder import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.outlined.PushPin import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector import com.zhangke.framework.utils.formatToHumanReadable import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.statusui.Res import com.zhangke.fread.statusui.ic_format_quote import com.zhangke.fread.statusui.ic_format_quote_in_left import com.zhangke.fread.statusui.ic_share import com.zhangke.fread.statusui.ic_status_comment import com.zhangke.fread.statusui.ic_status_forward import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource @Composable fun likeIcon(liked: Boolean): ImageVector { return if (liked) Icons.Default.Favorite else Icons.Default.FavoriteBorder } @Composable internal fun forwardIcon(): ImageVector { return vectorResource(Res.drawable.ic_status_forward) } @Composable fun quoteIcon(): ImageVector { return vectorResource(Res.drawable.ic_format_quote) } @Composable fun quoteInLeftIcon(): ImageVector { return vectorResource(Res.drawable.ic_format_quote_in_left) } @Composable fun replyIcon(): ImageVector { return vectorResource(Res.drawable.ic_status_comment) } @Composable internal fun bookmarkIcon(bookmarked: Boolean): ImageVector { return if (bookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder } @Composable internal fun deleteIcon(): ImageVector { return Icons.Default.Delete } @Composable internal fun shareIcon(): ImageVector { return vectorResource(Res.drawable.ic_share) } @Composable fun pinIcon(pinned: Boolean): ImageVector { return if (pinned) Icons.Default.PushPin else Icons.Outlined.PushPin } @Composable internal fun editIcon(): ImageVector { return Icons.Default.Edit } @Composable fun likeAlt(): String { return stringResource(LocalizedString.statusUiLike) } @Composable internal fun forwardAlt(): String { return stringResource(LocalizedString.status_ui_repost) } @Composable internal fun unforwardAlt(): String { return stringResource(LocalizedString.status_ui_action_unforward) } @Composable internal fun quoteAlt(): String { return stringResource(LocalizedString.statusUiQuote) } @Composable internal fun replyAlt(): String { return stringResource(LocalizedString.statusUiComment) } @Composable internal fun bookmarkAlt(bookmarked: Boolean): String { return if (bookmarked) stringResource(LocalizedString.statusUiUnbookmark) else stringResource( LocalizedString.statusUiBookmark ) } @Composable internal fun deleteAlt(): String { return stringResource(LocalizedString.statusUiDelete) } @Composable internal fun shareAlt(): String { return stringResource(LocalizedString.statusUiShare) } @Composable fun pinAlt(pinned: Boolean): String { return if (pinned) stringResource(LocalizedString.statusUiUnpin) else stringResource( LocalizedString.statusUiPin ) } @Composable internal fun editAlt(): String { return stringResource(LocalizedString.statusUiEdit) } internal fun Long.countToLabel(): String? { return when { this <= 0 -> null else -> this.formatToHumanReadable() } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/action/StatusMoreInteractionPanel.kt ================================================ package com.zhangke.fread.status.ui.action import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Language import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import com.zhangke.framework.composable.FreadDialog import com.zhangke.framework.composable.PopupMenu import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.common.handler.LocalTextHandler import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.model.BlogTranslationUiState import com.zhangke.fread.status.model.StatusActionType import com.zhangke.fread.status.ui.style.StatusStyle import com.zhangke.fread.statusui.Res import com.zhangke.fread.statusui.ic_more import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource @Composable fun StatusMoreInteractionIcon( modifier: Modifier, blog: Blog, isOwner: Boolean?, blogTranslationState: BlogTranslationUiState, style: StatusStyle, onActionClick: (StatusActionType, Blog) -> Unit, onTranslateClick: () -> Unit, onOpenBlogWithOtherAccountClick: (Blog) -> Unit, showOpenBlogWithOtherAccountBtn: Boolean = true, ) { var showMorePopup by remember { mutableStateOf(false) } Box(modifier = modifier) { StatusIconButton( modifier = Modifier .size(style.bottomPanelStyle.iconSize), onClick = { showMorePopup = !showMorePopup }, ) { Icon( imageVector = vectorResource(Res.drawable.ic_more), contentDescription = "More Options" ) } PopupMenu( expanded = showMorePopup, onDismissRequest = { showMorePopup = false }, ) { AdditionalMoreOptions( blog = blog, blogTranslationState = blogTranslationState, onDismissRequest = { showMorePopup = false }, onTranslateClick = onTranslateClick, onOpenBlogWithOtherAccountClick = onOpenBlogWithOtherAccountClick, showOpenBlogWithOtherAccountBtn = showOpenBlogWithOtherAccountBtn, ) if (isOwner == true) { InteractionItem( type = StatusActionType.PIN, icon = pinIcon(blog.pinned), actionName = pinAlt(blog.pinned), onDismissRequest = { showMorePopup = false }, onActionClick = { onActionClick(it, blog) }, ) InteractionItem( type = StatusActionType.DELETE, icon = deleteIcon(), actionName = deleteAlt(), onDismissRequest = { showMorePopup = false }, onActionClick = { onActionClick(it, blog) }, ) if (blog.supportEdit) { InteractionItem( type = StatusActionType.EDIT, icon = editIcon(), actionName = editAlt(), onDismissRequest = { showMorePopup = false }, onActionClick = { onActionClick(it, blog) }, ) } } } } } @Composable private fun InteractionItem( type: StatusActionType, actionName: String, icon: ImageVector, onDismissRequest: () -> Unit, onActionClick: (StatusActionType) -> Unit, ) { var showDeleteConfirmDialog by remember(type) { mutableStateOf(false) } DropdownMenuItem( text = { Text(text = actionName) }, leadingIcon = { Icon( imageVector = icon, contentDescription = actionName, ) }, onClick = { if (type == StatusActionType.DELETE) { showDeleteConfirmDialog = true } else { onDismissRequest() onActionClick(type) } }, ) if (showDeleteConfirmDialog) { FreadDialog( onDismissRequest = { onDismissRequest() showDeleteConfirmDialog = false }, contentText = stringResource(LocalizedString.statusUiDeleteStatusConfirm), onNegativeClick = { onDismissRequest() showDeleteConfirmDialog = false }, onPositiveClick = { onDismissRequest() showDeleteConfirmDialog = false onActionClick(type) }, ) } } @Composable private fun AdditionalMoreOptions( blog: Blog, blogTranslationState: BlogTranslationUiState, onDismissRequest: () -> Unit, onTranslateClick: () -> Unit, onOpenBlogWithOtherAccountClick: (Blog) -> Unit, showOpenBlogWithOtherAccountBtn: Boolean, ) { val textHandler = LocalTextHandler.current val browserLauncher = LocalActivityBrowserLauncher.current val coroutineScope = rememberCoroutineScope() DropDownOpenInBrowserItem { onDismissRequest() coroutineScope.launch { browserLauncher.launchWebTabInApp(blog.link, checkAppSupportPage = false) } } DropDownCopyLinkItem { onDismissRequest() textHandler.copyText(blog.link) } if (showOpenBlogWithOtherAccountBtn) { DropDownOpenStatusByOtherAccountItem { onDismissRequest() onOpenBlogWithOtherAccountClick(blog) } } if (blogTranslationState.support) { ModalDropdownMenuItem( text = stringResource(LocalizedString.statusUiInteractionTranslate), imageVector = Icons.Default.Language, onClick = { onDismissRequest() onTranslateClick() }, ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/bar/EditContentTopBar.kt ================================================ package com.zhangke.fread.status.ui.bar import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text 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.Modifier import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.FreadDialog import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.Toolbar import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.stringResource @Composable fun EditContentTopBar( contentName: String, onBackClick: () -> Unit, onNameEdit: (String) -> Unit, onDeleteClick: () -> Unit, ) { var showDeleteConfirmDialog by remember { mutableStateOf(false) } var showEditNameDialog by remember { mutableStateOf(false) } Toolbar( title = contentName, onBackClick = onBackClick, actions = { SimpleIconButton( onClick = { showEditNameDialog = true }, imageVector = Icons.Default.Edit, contentDescription = "Edit content name", ) SimpleIconButton( onClick = { showDeleteConfirmDialog = true }, imageVector = Icons.Default.Delete, contentDescription = "Delete content", ) } ) if (showDeleteConfirmDialog) { FreadDialog( onDismissRequest = { showDeleteConfirmDialog = false }, contentText = stringResource(LocalizedString.statusUiEditContentDeleteDialogContent), onNegativeClick = { showDeleteConfirmDialog = false }, onPositiveClick = { showDeleteConfirmDialog = false onDeleteClick() }, ) } if (showEditNameDialog) { EditContentNameDialog( name = contentName, onConfirmClick = onNameEdit, onDismissRequest = { showEditNameDialog = false }, ) } } @Composable private fun EditContentNameDialog( name: String, onConfirmClick: (String) -> Unit, onDismissRequest: () -> Unit, ) { var inputtingNote by remember { mutableStateOf(name) } FreadDialog( onDismissRequest = { onDismissRequest() }, title = stringResource(LocalizedString.statusUiEditContentNameTitle), content = { OutlinedTextField( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 16.dp), value = inputtingNote, onValueChange = { inputtingNote = it }, label = { Text( text = stringResource(LocalizedString.statusUiEditContentNameLabel) ) }, placeholder = { Text( text = stringResource(LocalizedString.statusUiEditContentNameHint) ) }, ) }, onNegativeClick = { onDismissRequest() }, onPositiveClick = { if (inputtingNote.isNotEmpty()) { onDismissRequest() onConfirmClick(inputtingNote) } }, ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/BlogTranslaction.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.BlogTranslationUiState import com.zhangke.fread.status.ui.style.StatusStyle import org.jetbrains.compose.resources.stringResource @Composable fun BlogTranslateLabel( modifier: Modifier, style: StatusStyle, blogTranslationState: BlogTranslationUiState?, onShowOriginalClick: () -> Unit, ) { if (blogTranslationState == null || !blogTranslationState.support) return if (!blogTranslationState.translating && !blogTranslationState.showingTranslation) return Row( modifier = modifier .padding(vertical = style.contentStyle.contentVerticalSpacing), verticalAlignment = Alignment.CenterVertically, ) { HorizontalDivider( modifier = Modifier.weight(1F) ) if (blogTranslationState.showingTranslation) { Text( modifier = Modifier .clickable { onShowOriginalClick() } .padding(horizontal = 16.dp), text = stringResource(LocalizedString.statusUiTranslateShowOriginal), style = style.infoLineStyle.descStyle, color = MaterialTheme.colorScheme.primary, ) } else { Text( modifier = Modifier .padding(horizontal = 16.dp), text = stringResource(LocalizedString.statusUiTranslating), style = style.infoLineStyle.descStyle, ) } HorizontalDivider( modifier = Modifier.weight(1F) ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/ContentToolbar.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.zhangke.framework.blur.applyBlurEffect import com.zhangke.framework.composable.ScrollTopAppBar import com.zhangke.framework.composable.ScrollTopAppBarColors import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.ToolbarTokens import com.zhangke.framework.composable.noRippleClick import com.zhangke.fread.status.account.LoggedAccount import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable fun ContentToolbar( modifier: Modifier = Modifier, title: String, account: LoggedAccount?, showAccountInfo: Boolean, showNextIcon: Boolean, showRefreshButton: Boolean, scrollBehavior: TopAppBarScrollBehavior, onMenuClick: () -> Unit, onRefreshClick: () -> Unit, onNextClick: () -> Unit, onTitleClick: () -> Unit, onDoubleClick: (() -> Unit)? = null, ) { val scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer val surfaceColor = MaterialTheme.colorScheme.surface ScrollTopAppBar( modifier = modifier.applyBlurEffect(containerColor = surfaceColor) .pointerInput(onDoubleClick) { detectTapGestures(onDoubleTap = { onDoubleClick?.invoke() }) }, scrollBehavior = scrollBehavior, colors = ScrollTopAppBarColors.default(scrolledContainerColor = scrolledContainerColor), navigationIcon = { SimpleIconButton( onClick = onMenuClick, imageVector = Icons.Default.Menu, contentDescription = "Menu", ) }, title = { Column { Text( modifier = Modifier.noRippleClick { onTitleClick() }, text = title, style = ToolbarTokens.titleTextStyle, maxLines = 1, overflow = TextOverflow.Ellipsis, ) if (account != null && showAccountInfo) { Text( modifier = Modifier.padding(top = 1.dp), text = account.prettyHandle, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } }, actions = { if (showRefreshButton) { SimpleIconButton( modifier = Modifier, onClick = { onRefreshClick() }, imageVector = Icons.Default.Refresh, contentDescription = "Next Content" ) } if (showNextIcon) { SimpleIconButton( modifier = Modifier, onClick = { onNextClick() }, imageVector = Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "Next Content" ) } }, ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/DetailHeaderContent.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.LocalTextStyle 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.text.buildAnnotatedString 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.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import com.zhangke.framework.composable.freadPlaceholder import com.zhangke.framework.utils.Log import com.zhangke.fread.status.richtext.RichText import com.zhangke.fread.status.ui.richtext.SelectableRichText @Composable fun DetailHeaderContent( progress: Float, loading: Boolean, banner: String?, avatar: String?, title: RichText?, description: RichText?, acctLine: @Composable () -> Unit, followInfo: @Composable () -> Unit, onBannerClick: () -> Unit, onAvatarClick: () -> Unit, onUrlClick: (String) -> Unit, onMaybeHashtagClick: (String) -> Unit, privateNote: String? = null, action: (@Composable () -> Unit)? = null, bottomArea: (@Composable () -> Unit)? = null, ) { SelectionContainer { Surface(modifier = Modifier.fillMaxWidth()) { ConstraintLayout( modifier = Modifier.fillMaxWidth(), ) { val (bannerRef, avatarRef, relationBtnRef, nameRef) = createRefs() val (acctRef, privateNoteRef, noteRef, followRef) = createRefs() val bottomAreaRef = createRef() ProgressedBanner( modifier = Modifier .clickable { onBannerClick() } .constrainAs(bannerRef) { top.linkTo(parent.top) start.linkTo(parent.start) end.linkTo(parent.end) width = Dimension.fillToConstraints }, url = banner, ) // avatar ProgressedAvatar( modifier = Modifier.constrainAs(avatarRef) { start.linkTo(parent.start, 16.dp) top.linkTo(bannerRef.bottom) bottom.linkTo(bannerRef.bottom) }, avatar = avatar, progress = progress, loading = loading, onAvatarClick = onAvatarClick, ) // relationship button if (action == null || loading) { Box( modifier = Modifier.constrainAs(relationBtnRef) { top.linkTo(bannerRef.bottom, 16.dp) end.linkTo(parent.end, 16.dp) width = Dimension.value(0.dp) } ) } else { Box( modifier = Modifier.constrainAs(relationBtnRef) { top.linkTo(bannerRef.bottom, 4.dp) end.linkTo(parent.end, 16.dp) } ) { action() } } // title SelectableRichText( modifier = Modifier .freadPlaceholder(loading) .constrainAs(nameRef) { top.linkTo(avatarRef.bottom, 8.dp) start.linkTo(parent.start, 16.dp) if (loading) { width = Dimension.value(68.dp) } else { end.linkTo(parent.end, 16.dp) width = Dimension.fillToConstraints } }, richText = title ?: RichText.empty, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 18.sp, onUrlClick = onUrlClick, ) // acct line Box( modifier = Modifier .widthIn(min = 36.dp) .freadPlaceholder(loading) .constrainAs(acctRef) { top.linkTo(nameRef.bottom, 6.dp) start.linkTo(nameRef.start) width = Dimension.wrapContent }, contentAlignment = Alignment.CenterStart, ) { acctLine() } // private note if (privateNote.isNullOrEmpty()) { Box(modifier = Modifier.constrainAs(privateNoteRef) { top.linkTo(acctRef.bottom) start.linkTo(nameRef.start) }) } else { Box( modifier = Modifier.constrainAs(privateNoteRef) { top.linkTo(acctRef.bottom, 6.dp) start.linkTo(nameRef.start) end.linkTo(parent.end, 16.dp) width = Dimension.fillToConstraints }, ) { val privateNoteStr = buildAnnotatedString { val prefix = "NOTE: " append(prefix) append(privateNote) } SelectionContainer { Text( modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Start, text = privateNoteStr, style = MaterialTheme.typography.labelLarge, ) } } } // description SelectableRichText( modifier = Modifier .freadPlaceholder(loading) .fillMaxWidth() .constrainAs(noteRef) { top.linkTo(privateNoteRef.bottom, 6.dp) start.linkTo(nameRef.start) end.linkTo(parent.end, 16.dp) width = Dimension.fillToConstraints }, richText = description ?: RichText.empty, onUrlClick = onUrlClick, fontSize = 14.sp, onMaybeHashtagClick = onMaybeHashtagClick, ) // follow info line Box( modifier = Modifier .padding(top = 4.dp) .freadPlaceholder(loading) .constrainAs(followRef) { top.linkTo(noteRef.bottom) start.linkTo(noteRef.start) width = Dimension.wrapContent }, ) { followInfo() } // bottom area Box( modifier = Modifier.Companion.constrainAs(bottomAreaRef) { top.linkTo(followRef.bottom, 8.dp) start.linkTo(followRef.start) end.linkTo(parent.end, 16.dp) bottom.linkTo(parent.bottom, 8.dp) width = Dimension.fillToConstraints }, ) { bottomArea?.invoke() } } } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/DetailPageScaffold.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.zhangke.framework.blur.BlurController import com.zhangke.framework.blur.LocalBlurController import com.zhangke.framework.blur.rememberBlurController import com.zhangke.framework.composable.LocalContentPadding import com.zhangke.framework.composable.LocalSnackbarHostState import com.zhangke.framework.composable.collapsable.ScrollUpTopBarLayout import com.zhangke.framework.composable.plusContentPadding import com.zhangke.fread.status.richtext.RichText @Composable fun DetailPageScaffold( modifier: Modifier, snackbarHostState: SnackbarHostState, title: RichText, avatar: String, banner: String?, description: RichText?, privateNote: String?, loading: Boolean, contentCanScrollBackward: MutableState, onBannerClick: () -> Unit, onAvatarClick: () -> Unit, onUrlClick: (String) -> Unit, onMaybeHashtagClick: (String) -> Unit, onBackClick: () -> Unit, topBarActions: @Composable RowScope.() -> Unit, handleLine: @Composable () -> Unit, followInfoLine: @Composable () -> Unit, topDetailContentAction: (@Composable () -> Unit)? = null, bottomArea: (@Composable () -> Unit)? = null, content: @Composable (progress: Float) -> Unit, ) { DetailPageScaffold( modifier = modifier, snackbarHostState = snackbarHostState, title = title, contentCanScrollBackward = contentCanScrollBackward, onBackClick = onBackClick, topBarActions = topBarActions, topDetailContent = { progress -> DetailHeaderContent( progress = progress, loading = loading, banner = banner, avatar = avatar, title = title, description = description, acctLine = handleLine, followInfo = followInfoLine, onBannerClick = onBannerClick, onAvatarClick = onAvatarClick, onUrlClick = onUrlClick, onMaybeHashtagClick = onMaybeHashtagClick, privateNote = privateNote, action = topDetailContentAction, bottomArea = bottomArea, ) }, content = content, ) } @Composable fun DetailPageScaffold( modifier: Modifier, snackbarHostState: SnackbarHostState, title: RichText, contentCanScrollBackward: MutableState, onBackClick: () -> Unit, topBarActions: @Composable RowScope.() -> Unit, topDetailContent: @Composable BoxScope.(Float) -> Unit, content: @Composable (progress: Float) -> Unit, ) { Scaffold( modifier = modifier, snackbarHost = { SnackbarHost( modifier = Modifier.navigationBarsPadding(), hostState = snackbarHostState, ) }, contentWindowInsets = WindowInsets(0, 0, 0, 0), ) { innerPaddings -> val blurController = rememberBlurController() CompositionLocalProvider( LocalSnackbarHostState provides snackbarHostState, LocalBlurController provides blurController, ) { ScrollUpTopBarLayout( modifier = Modifier .fillMaxSize() .padding(innerPaddings), topBarContent = { progress -> DetailTopBar( progress = progress, title = title, onBackClick = onBackClick, actions = topBarActions, ) }, headerContent = { progress -> topDetailContent(progress) }, contentCanScrollBackward = contentCanScrollBackward, ) { paddingValues, progress -> CompositionLocalProvider( LocalContentPadding provides plusContentPadding(paddingValues), ) { content(progress) } } } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/DetailTopBar.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.statusBars import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.lerp import androidx.compose.ui.unit.sp import com.zhangke.framework.blur.applyBlurEffect import com.zhangke.framework.blur.blurEffectContainerColor import com.zhangke.framework.composable.SingleRowTopAppBar import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.TopAppBarColors import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.status.richtext.RichText import com.zhangke.fread.status.ui.richtext.FreadRichText import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun DetailTopBar( progress: Float, title: RichText, onBackClick: () -> Unit, actions: @Composable RowScope.() -> Unit, ) { val progressTopBarContainerColor = MaterialTheme.colorScheme.surface.copy(progress) val blurEnabled = progress >= 1F val onTopBarColor = lerp( start = MaterialTheme.colorScheme.inverseOnSurface, stop = MaterialTheme.colorScheme.onSurface, fraction = progress, ) val browserLauncher = LocalActivityBrowserLauncher.current val coroutineScope = rememberCoroutineScope() SingleRowTopAppBar( modifier = Modifier.applyBlurEffect( enabled = blurEnabled, containerColor = progressTopBarContainerColor, ), title = { if (progress >= 1F) { FreadRichText( modifier = Modifier, richText = title, fontSize = 22.sp, maxLines = 1, onUrlClick = { coroutineScope.launch { browserLauncher.launchWebTabInApp(it) } }, ) } }, navigationIcon = { Toolbar.BackButton(onBackClick = onBackClick) }, windowInsets = WindowInsets.statusBars, colors = TopAppBarColors.default( containerColor = blurEffectContainerColor( enabled = blurEnabled, containerColor = progressTopBarContainerColor, ), navigationIconContentColor = onTopBarColor, actionIconContentColor = onTopBarColor, ), actions = actions, ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/HomeContentTabsTopBar.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.TabsTopAppBar import com.zhangke.framework.composable.TabsTopAppBarColors import com.zhangke.framework.composable.ToolbarTokens import com.zhangke.framework.composable.noRippleClick import com.zhangke.fread.status.account.LoggedAccount @Composable @OptIn(ExperimentalMaterial3Api::class) fun HomeContentTabsTopBar( modifier: Modifier = Modifier, selectedTabIndex: Int, scrollBehavior: TopAppBarScrollBehavior, tabCount: Int, tabContent: @Composable (index: Int) -> Unit, onTabClick: (index: Int) -> Unit, navigationIcon: @Composable () -> Unit = {}, title: @Composable () -> Unit, actions: @Composable RowScope.() -> Unit = {}, ) { val scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer TabsTopAppBar( modifier = modifier, navigationIcon = navigationIcon, title = title, actions = actions, selectedTabIndex = selectedTabIndex, scrollBehavior = scrollBehavior, colors = TabsTopAppBarColors.default(scrolledContainerColor = scrolledContainerColor), tabContent = tabContent, tabCount = tabCount, onTabClick = onTabClick, ) } @Composable @OptIn(ExperimentalMaterial3Api::class) fun HomeContentTabsTopBar( modifier: Modifier = Modifier, title: String, account: LoggedAccount?, showAccountInfo: Boolean, selectedTabIndex: Int, tabTitles: List, scrollBehavior: TopAppBarScrollBehavior, showNextIcon: Boolean = false, showRefreshButton: Boolean = false, onMenuClick: () -> Unit, onRefreshClick: () -> Unit, onNextClick: () -> Unit, onTitleClick: () -> Unit, onDoubleClick: (() -> Unit)? = null, onTabClick: (index: Int) -> Unit, ) { HomeContentTabsTopBar( modifier = modifier.doubleTapToScrollTop(onDoubleClick), selectedTabIndex = selectedTabIndex, tabCount = tabTitles.size, scrollBehavior = scrollBehavior, onTabClick = onTabClick, navigationIcon = { SimpleIconButton( onClick = onMenuClick, imageVector = Icons.Default.Menu, contentDescription = "Menu", ) }, title = { Column( modifier = Modifier ) { Text( modifier = Modifier.titleClickable(onTitleClick), text = title, maxLines = 1, style = ToolbarTokens.titleTextStyle, overflow = TextOverflow.Ellipsis, ) if (account != null && showAccountInfo) { Text( modifier = Modifier.padding(top = 1.dp), text = account.prettyHandle, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } }, actions = { if (showRefreshButton) { SimpleIconButton( onClick = onRefreshClick, imageVector = Icons.Default.Refresh, contentDescription = "Refresh", ) } if (showNextIcon) { SimpleIconButton( onClick = onNextClick, imageVector = Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "Next Content", ) } }, tabContent = { index -> Text( text = tabTitles[index], maxLines = 1, ) }, ) } @Composable private fun Modifier.titleClickable(onClick: () -> Unit): Modifier { return this.noRippleClick { onClick() } } fun Modifier.doubleTapToScrollTop(onDoubleClick: (() -> Unit)?): Modifier = composed { if (onDoubleClick == null) { this } else { this.pointerInput(onDoubleClick) { detectTapGestures(onDoubleTap = { onDoubleClick() }) } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/LinkPreviewCard.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Description import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonColors import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale 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 com.seiko.imageloader.model.ImageRequest import com.seiko.imageloader.ui.AutoSizeImage import com.zhangke.framework.composable.TextWithIcon import com.zhangke.framework.composable.freadPlaceholder import com.zhangke.framework.ktx.ifNullOrEmpty import com.zhangke.framework.utils.LinkPreviewInfo import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.stringResource @Composable fun LinkPreviewCard( modifier: Modifier, card: DetectedLinkCard, onRemoveClick: () -> Unit, ) { when (card) { is DetectedLinkCard.Deleted -> { Box(modifier = modifier) } is DetectedLinkCard.Loading -> { PreviewCardLoading( modifier = modifier, link = card.link, onRemoveClick = onRemoveClick, ) } is DetectedLinkCard.Loaded -> { PreviewCardLoaded( modifier = modifier, link = card.link, info = card.info, onRemoveClick = onRemoveClick, ) } is DetectedLinkCard.Failure -> { PreviewCardFailure( modifier = modifier, link = card.link, throwable = card.throwable, onRemoveClick = onRemoveClick, ) } } } @Composable private fun PreviewCardLoading(modifier: Modifier, link: String, onRemoveClick: () -> Unit) { PreviewCardContainer( modifier = modifier, onRemoveClick = onRemoveClick, link = link, ) { Box( modifier = Modifier.height(16.dp) .fillMaxWidth(0.3F) .freadPlaceholder(true), ) Box( modifier = Modifier.padding(top = 16.dp) .height(16.dp) .fillMaxWidth(0.6F) .freadPlaceholder(true), ) Box( modifier = Modifier.padding(top = 16.dp) .height(16.dp) .fillMaxWidth(0.9F) .freadPlaceholder(true), ) } } @Composable private fun PreviewCardLoaded( modifier: Modifier, link: String, info: LinkPreviewInfo, onRemoveClick: () -> Unit, ) { if (info.image.isNullOrEmpty()) { Row( modifier = modifier.height(86.dp).previewCardBorder(), ) { Box( modifier = Modifier .padding(end = 10.dp) .fillMaxHeight() .aspectRatio(1F) .background( color = MaterialTheme.colorScheme.surfaceDim, shape = RoundedCornerShape(8.dp), ), ) { Icon( modifier = Modifier .align(Alignment.Center) .size(36.dp), imageVector = Icons.Default.Description, contentDescription = "Link", tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7F), ) } Column( modifier = Modifier .weight(1F) .padding(end = 8.dp), verticalArrangement = Arrangement.Center, ) { Text( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), textAlign = TextAlign.Start, text = info.title, maxLines = 2, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, overflow = TextOverflow.Ellipsis, ) Text( modifier = Modifier .fillMaxWidth() .padding(top = 2.dp) .padding(horizontal = 16.dp), textAlign = TextAlign.Start, text = info.description.ifNullOrEmpty { info.siteName.ifNullOrEmpty { link } }, maxLines = 3, fontSize = 14.sp, overflow = TextOverflow.Ellipsis, ) } DeleteButton( modifier = Modifier.padding(top = 8.dp, end = 8.dp), onClick = onRemoveClick, ) } } else { Column( modifier = modifier.previewCardBorder() ) { Box( modifier = Modifier.fillMaxWidth().aspectRatio(2F), ) { AutoSizeImage( remember(info.image) { ImageRequest(info.image.orEmpty()) }, modifier = Modifier .clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) .fillMaxSize(), contentScale = ContentScale.Crop, contentDescription = "Preview Image", ) DeleteButton( modifier = Modifier.align(Alignment.TopEnd) .padding(top = 8.dp, end = 8.dp), onClick = onRemoveClick, ) } Text( modifier = Modifier .fillMaxWidth() .padding(top = 8.dp) .padding(horizontal = 16.dp), textAlign = TextAlign.Start, text = info.title, maxLines = 2, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, overflow = TextOverflow.Ellipsis, ) if (!info.description.isNullOrEmpty()) { Text( modifier = Modifier .fillMaxWidth() .padding(top = 4.dp) .padding(horizontal = 16.dp), textAlign = TextAlign.Start, text = info.description.orEmpty(), maxLines = 3, fontSize = 14.sp, overflow = TextOverflow.Ellipsis, ) } HorizontalDivider( modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 12.dp, end = 16.dp) ) TextWithIcon( modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), text = info.siteName.ifNullOrEmpty { info.url }, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } @Composable private fun PreviewCardFailure( modifier: Modifier, link: String, throwable: Throwable?, onRemoveClick: () -> Unit, ) { PreviewCardContainer( modifier = modifier, onRemoveClick = onRemoveClick, link = link, ) { Text( modifier = Modifier.padding(top = 16.dp).align(Alignment.CenterHorizontally), text = stringResource(LocalizedString.loadMoreError), fontWeight = FontWeight.SemiBold, ) if (!throwable?.message.isNullOrEmpty()) { Text( modifier = Modifier.padding(top = 8.dp).align(Alignment.CenterHorizontally), text = throwable.message.orEmpty(), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } @Composable private fun PreviewCardContainer( modifier: Modifier, link: String, onRemoveClick: () -> Unit, content: @Composable ColumnScope.() -> Unit, ) { Box(modifier = modifier.previewCardBorder()) { Text( modifier = Modifier.align(Alignment.TopCenter) .fillMaxWidth() .padding(start = 46.dp, top = 16.dp, end = 46.dp), text = link, textAlign = TextAlign.Center, maxLines = 1, color = MaterialTheme.colorScheme.onSurfaceVariant, overflow = TextOverflow.Ellipsis, ) DeleteButton( modifier = Modifier.align(Alignment.TopEnd).padding(top = 8.dp, end = 8.dp), onClick = onRemoveClick, ) Column( modifier = Modifier.fillMaxWidth() .padding(start = 16.dp, top = 46.dp, end = 16.dp, bottom = 16.dp), ) { content() } } } @Composable private fun DeleteButton( modifier: Modifier, onClick: () -> Unit, colors: IconButtonColors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, ), ) { IconButton( modifier = modifier, onClick = onClick, colors = colors, ) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(LocalizedString.statusUiDelete), ) } } @Composable private fun Modifier.previewCardBorder(): Modifier { return this.border( width = 1.dp, shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.outlineVariant, ) } sealed interface DetectedLinkCard { val link: String data class Loading(override val link: String) : DetectedLinkCard data class Loaded(override val link: String, val info: LinkPreviewInfo) : DetectedLinkCard data class Failure(override val link: String, val throwable: Throwable?) : DetectedLinkCard data class Deleted(override val link: String) : DetectedLinkCard } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/NestedTabConnection.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.staticCompositionLocalOf import com.zhangke.fread.status.model.FreadContent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds val LocalNestedTabConnection = staticCompositionLocalOf { NestedTabConnection() } class NestedTabConnection { companion object { private val IMMERSIVE_MODE_DELAY = 500.milliseconds } private val _switchToNextTabFlow = MutableSharedFlow() val switchToNextTabFlow: SharedFlow get() = _switchToNextTabFlow.asSharedFlow() private val _openDrawerFlow = MutableSharedFlow() val openDrawerFlow: SharedFlow get() = _openDrawerFlow.asSharedFlow() private val _inImmersiveFlow = MutableStateFlow(false) val inImmersiveFlow: StateFlow get() = _inImmersiveFlow.asStateFlow() private val _scrollToContentTabFlow = MutableSharedFlow() val scrollToContentTabFlow: SharedFlow get() = _scrollToContentTabFlow.asSharedFlow() private val _scrollToTopFlow = MutableSharedFlow() val scrollToTopFlow: SharedFlow get() = _scrollToTopFlow.asSharedFlow() private val _contentScrollInpProgress = MutableStateFlow(false) val contentScrollInpProgress: StateFlow get() = _contentScrollInpProgress.asStateFlow() private val _refreshFlow = MutableSharedFlow() val refreshFlow: SharedFlow get() = _refreshFlow.asSharedFlow() private var toggleImmersiveJob: Job? = null suspend fun switchToNextTab() { _switchToNextTabFlow.emit(Unit) } suspend fun openDrawer() { _openDrawerFlow.emit(Unit) } fun openImmersiveMode(coroutineScope: CoroutineScope) { toggleImmersiveJob?.cancel() toggleImmersiveJob = coroutineScope.launch { delay(IMMERSIVE_MODE_DELAY) _inImmersiveFlow.emit(true) } } fun closeImmersiveMode(coroutineScope: CoroutineScope) { toggleImmersiveJob?.cancel() toggleImmersiveJob = coroutineScope.launch { delay(IMMERSIVE_MODE_DELAY) _inImmersiveFlow.emit(false) } } fun updateContentScrollInProgress(scrollInProgress: Boolean) { _contentScrollInpProgress.value = scrollInProgress } suspend fun scrollToContentTab(contentConfig: FreadContent) { _scrollToContentTabFlow.emit(contentConfig) } suspend fun scrollToTop() { _scrollToTopFlow.emit(Unit) } suspend fun refresh() { _refreshFlow.emit(Unit) } } @Composable fun ObserveScrollInProgressForConnection(lazyListState: LazyListState) { val nestedTabConnection = LocalNestedTabConnection.current LaunchedEffect(lazyListState.isScrollInProgress) { nestedTabConnection.updateContentScrollInProgress(lazyListState.isScrollInProgress) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/NewStatusNotifyBar.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.stringResource @Composable fun NewStatusNotifyBar( modifier: Modifier, onClick: () -> Unit, ) { Button( modifier = modifier .height(40.dp) .clickable { onClick() }, shape = RoundedCornerShape(20.dp), onClick = onClick, ) { Row( verticalAlignment = Alignment.CenterVertically, ) { Icon( modifier = Modifier.size(18.dp), imageVector = Icons.Default.ArrowUpward, contentDescription = "Scroll Up", ) val tip = stringResource(LocalizedString.statusUiNewStatus) Text( modifier = Modifier.padding(start = 4.dp), text = tip, ) } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/ObserveMaxReadItem.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @Composable fun ObserveMinReadItem(listState: LazyListState, onReadMinIndex: (index: Int) -> Unit) { var minIndex by remember { mutableIntStateOf(Int.MAX_VALUE) } val firstVisibleItemIndex by remember { derivedStateOf { listState.firstVisibleItemIndex } } if (firstVisibleItemIndex < minIndex && !listState.isScrollInProgress) { minIndex = firstVisibleItemIndex LaunchedEffect(minIndex) { onReadMinIndex(minIndex) } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/ObserveScrollStopedPosition.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.snapshotFlow import kotlinx.coroutines.flow.distinctUntilChanged @Composable fun ObserveScrollStopedPosition( listState: LazyListState, onPositionChanged: (position: Int) -> Unit, ) { LaunchedEffect(listState) { snapshotFlow { listState.isScrollInProgress } .distinctUntilChanged() .collect { inProgress -> if (!inProgress) { val index = listState.firstVisibleItemIndex onPositionChanged(index) } } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/PostStatusTextVisualTransformation.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import com.zhangke.fread.common.utils.HashtagTextUtils import com.zhangke.fread.common.utils.LinkTextUtils import com.zhangke.fread.common.utils.MentionTextUtil class PostStatusTextVisualTransformation( private val highLightColor: Color, private val enableMentions: Boolean = true, private val allowHashtagInHashtag: Boolean = false, ) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { val hashtags = HashtagTextUtils.findHashtags(text.text, allowHashtagInHashtag) val mentions = if (enableMentions) MentionTextUtil.findMentionList(text.text) else emptyList() val links = LinkTextUtils.findLinks(text.text) val highlightList = hashtags + mentions + links return TransformedText( text = buildAnnotatedString { append(text) highlightList.forEach { addStyle( style = SpanStyle( color = highLightColor, ), start = it.start, end = it.end, ) } }, offsetMapping = OffsetMapping.Identity, ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/ProgressedAvatar.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import com.seiko.imageloader.ui.AutoSizeImage import com.zhangke.framework.composable.freadPlaceholder @Composable fun ProgressedAvatar( modifier: Modifier, avatar: String?, loading: Boolean, progress: Float, onAvatarClick: () -> Unit, ) { AutoSizeImage( url = avatar.orEmpty(), modifier = modifier .scale(1F - progress) .clip(CircleShape) .border(2.dp, Color.White, CircleShape) .clickable { onAvatarClick() } .freadPlaceholder(loading) .size(80.dp), contentScale = ContentScale.Crop, contentDescription = "avatar", ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/ProgressedBanner.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Brush import androidx.compose.ui.layout.ContentScale import com.seiko.imageloader.ui.AutoSizeImage import com.zhangke.fread.statusui.Res import com.zhangke.fread.statusui.img_banner_background import org.jetbrains.compose.resources.painterResource private const val BANNER_ASPECT = 2.6F @Composable fun ProgressedBanner( modifier: Modifier, url: String?, ) { Box(modifier = modifier.aspectRatio(BANNER_ASPECT)) { Image( modifier = Modifier.fillMaxSize(), painter = painterResource(Res.drawable.img_banner_background), contentDescription = null, contentScale = ContentScale.Crop, ) val maskColor = MaterialTheme.colorScheme.inverseSurface AutoSizeImage( url.orEmpty(), modifier = Modifier .fillMaxSize() .drawWithContent { drawContent() drawRect( brush = Brush.verticalGradient( colors = listOf( maskColor.copy(alpha = 0.3F), maskColor.copy(alpha = 0F), ), ), ) }, contentScale = ContentScale.Crop, contentDescription = "banner", ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/PublishingFab.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.contentBottomPadding @Composable fun PublishingFab( visible: Boolean, modifier: Modifier = Modifier, onPublishClick: () -> Unit, bottomPadding: Dp = 32.dp, ) { AnimatedVisibility( modifier = modifier .navigationBarsPadding() .contentBottomPadding() .padding(bottom = bottomPadding), visible = visible, enter = scaleIn() + slideInVertically(initialOffsetY = { it }), exit = scaleOut() + slideOutVertically(targetOffsetY = { it }), ) { FloatingActionButton( onClick = onPublishClick, containerColor = MaterialTheme.colorScheme.surface, contentColor = MaterialTheme.colorScheme.primary, shape = CircleShape, ) { Icon( imageVector = Icons.Default.Edit, contentDescription = "Post Micro Blog", ) } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/RelationshipStateButton.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.Modifier import com.zhangke.framework.composable.AlertConfirmDialog import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.Relationships import org.jetbrains.compose.resources.stringResource @Composable fun RelationshipStateButton( modifier: Modifier, relationship: Relationships, onUnblockClick: () -> Unit, onFollowClick: () -> Unit, onUnfollowClick: () -> Unit, onCancelFollowRequestClick: () -> Unit, ) { var showUnfollowDialog by remember { mutableStateOf(false) } when { relationship.blocking -> { var showDialog by remember { mutableStateOf(false) } Button( modifier = modifier, onClick = { showDialog = true }, ) { Text(text = stringResource(LocalizedString.statusUiUserDetailRelationshipBlocking)) } if (showDialog) { AlertConfirmDialog( content = stringResource(LocalizedString.statusUiRelationshipBtnDialogContentCancelBlocking), onConfirm = onUnblockClick, onDismissRequest = { showDialog = false } ) } } relationship.requested == true -> { var showDialog by remember { mutableStateOf(false) } FilledTonalButton( modifier = modifier, onClick = { showDialog = true }, colors = ButtonDefaults.outlinedButtonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, ), ) { Text(text = stringResource(LocalizedString.statusUiUserDetailRelationshipRequested)) } if (showDialog) { AlertConfirmDialog( content = stringResource(LocalizedString.statusUiRelationshipBtnDialogContentCancelFollowRequest), onConfirm = onCancelFollowRequestClick, onDismissRequest = { showDialog = false } ) } } relationship.following && relationship.followedBy -> { FilledTonalButton( modifier = modifier, onClick = { showUnfollowDialog = true }, colors = ButtonDefaults.outlinedButtonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, ), ) { Text(text = stringResource(LocalizedString.statusUiUserDetailRelationshipMutuals)) } } relationship.following -> { FilledTonalButton( modifier = modifier, onClick = { showUnfollowDialog = true }, ) { Text(text = stringResource(LocalizedString.statusUiUserDetailRelationshipFollowing)) } } relationship.followedBy -> { Button( modifier = modifier, onClick = onFollowClick, ) { Text(text = stringResource(LocalizedString.statusUiUserDetailRelationshipFollowBack)) } } else -> { Button( modifier = modifier, onClick = onFollowClick, ) { Text(text = stringResource(LocalizedString.statusUiUserDetailRelationshipNotFollow)) } } } if (showUnfollowDialog) { AlertConfirmDialog( content = stringResource(LocalizedString.statusUiRelationshipBtnDialogContentCancelFollow), onConfirm = onUnfollowClick, onDismissRequest = { showUnfollowDialog = false } ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/RemainingTextStatus.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.Text import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun RemainingTextStatus( modifier: Modifier, maxCount: Int, contentLength: Int, ) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, ) { val remainingCount = maxCount - contentLength val overLength = remainingCount < 0 val fontColor = if (overLength) { MaterialTheme.colorScheme.error } else { MaterialTheme.colorScheme.primary } Text( text = "${maxCount - contentLength}", style = MaterialTheme.typography.labelSmall, color = fontColor, maxLines = 1, ) CircularProgressIndicator( modifier = Modifier.padding(start = 4.dp) .size(20.dp), progress = { contentLength / maxCount.toFloat() }, trackColor = MaterialTheme.colorScheme.secondaryContainer, color = fontColor, ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/SelectAccountDialog.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check 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.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.zhangke.framework.composable.noRippleClick import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.ui.BlogAuthorAvatar import com.zhangke.fread.status.ui.richtext.RichTextWithIcon import org.jetbrains.compose.resources.stringResource @Composable fun SelectAccountDialog( accountList: List, selectedAccounts: List, onDismissRequest: () -> Unit, onAccountClicked: (LoggedAccount) -> Unit, ) { Dialog(onDismissRequest = onDismissRequest) { Surface( shape = RoundedCornerShape(16.dp), shadowElevation = 6.dp, ) { Column( modifier = Modifier.fillMaxWidth() .padding(16.dp) .verticalScroll(rememberScrollState()), ) { Text( modifier = Modifier, text = stringResource(LocalizedString.statusUiSwitchAccountDialogTitle), style = MaterialTheme.typography.titleMedium .copy(fontWeight = FontWeight.SemiBold), ) for (account in accountList) { Spacer(modifier = Modifier.height(16.dp)) SelectableAccount( account = account, selected = selectedAccounts.any { account.uri == it.uri }, onClick = { onDismissRequest() onAccountClicked(account) }, ) } } } } } @Composable private fun SelectableAccount( account: LoggedAccount, selected: Boolean, onClick: (LoggedAccount) -> Unit, ) { Row( modifier = Modifier.fillMaxWidth().noRippleClick { onClick(account) }, verticalAlignment = Alignment.CenterVertically, ) { BlogAuthorAvatar( modifier = Modifier.size(42.dp), imageUrl = account.avatar, ) Column( modifier = Modifier.padding(start = 16.dp).weight(1F), ) { RichTextWithIcon( text = account.humanizedName, style = MaterialTheme.typography.titleMedium .copy(fontWeight = FontWeight.SemiBold), maxLines = 1, overflow = TextOverflow.Ellipsis, endIcon = { if (selected) { Spacer(modifier = Modifier.width(2.dp)) Icon( modifier = Modifier.size(18.dp), imageVector = Icons.Default.Check, tint = MaterialTheme.colorScheme.primary, contentDescription = null, ) } } ) Text( text = account.prettyHandle, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/StatusSharedElementConfig.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf data class StatusSharedElementConfig( val wholeBlogEnabled: Boolean, val imageAttachmentEnabled: Boolean, val label: String, ) { fun buildImageKey(key: String): String { return buildKey(label, key) } companion object { fun default(): StatusSharedElementConfig { return StatusSharedElementConfig( wholeBlogEnabled = true, imageAttachmentEnabled = true, label = "feeds" ) } fun buildKey(label: String, key: String): String { return "$label-$key" } } } val LocalStatusSharedElementConfig: ProvidableCompositionLocal = compositionLocalOf { StatusSharedElementConfig.default() } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/common/UserFollowLine.kt ================================================ package com.zhangke.fread.status.ui.common import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.zhangke.framework.utils.formatToHumanReadable import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource @Composable fun UserFollowLine( modifier: Modifier, followersCount: Long?, followingCount: Long?, statusesCount: Long?, isHighlightBigger: Boolean = true, onFollowerClick: (() -> Unit)? = null, onFollowingClick: (() -> Unit)? = null, ) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, ) { CountInfoItem( count = followersCount, descId = LocalizedString.statusUiUserDetailFollowerInfo, onClick = onFollowerClick, isHighlightBigger = isHighlightBigger, ) Text( modifier = Modifier .padding(horizontal = 4.dp), text = "·", style = MaterialTheme.typography.bodySmall, ) CountInfoItem( count = followingCount, descId = LocalizedString.statusUiUserDetailFollowingInfo, onClick = onFollowingClick, isHighlightBigger = isHighlightBigger, ) Text( modifier = Modifier .padding(horizontal = 4.dp), text = "·", style = MaterialTheme.typography.bodySmall, ) CountInfoItem( count = statusesCount, isHighlightBigger = isHighlightBigger, descId = LocalizedString.statusUiUserDetailPosts, ) } } @Composable private fun CountInfoItem( count: Long?, descId: StringResource, isHighlightBigger: Boolean, onClick: (() -> Unit)? = null, ) { val descSuffix = stringResource(descId) val info = remember(count) { if (count == null) { buildAnnotatedString { append(" ") } } else { buildCountedDesc(count, descSuffix, isHighlightBigger) } } Text( modifier = Modifier.clickable(count != null && onClick != null) { onClick?.invoke() }, text = info, style = MaterialTheme.typography.bodySmall, ) } private fun buildCountedDesc( count: Long, desc: String, isHighlightBigger: Boolean, ): AnnotatedString { val formattedCount = count.formatToHumanReadable() return buildAnnotatedString { append(formattedCount) addStyle( style = SpanStyle( fontSize = if (isHighlightBigger) 16.sp else TextUnit.Unspecified, fontWeight = FontWeight.Medium, ), start = 0, end = formattedCount.length, ) append(" ") append(desc) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/embed/BlogEmbedsUi.kt ================================================ package com.zhangke.fread.status.ui.embed import androidx.compose.foundation.border import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DividerDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.blog.BlogEmbed import com.zhangke.fread.status.ui.style.StatusStyle @Composable internal fun BlogEmbedsUi( modifier: Modifier, embeds: List, style: StatusStyle, onContentClick: (Blog) -> Unit, onUrlClick: (url: String) -> Unit, onUnavailableQuoteClick: (String) -> Unit, ) { if (embeds.isEmpty()) return embeds.forEach { embed -> BlogEmbedUi( modifier = modifier .padding(top = style.contentStyle.contentVerticalSpacing), embed = embed, style = style, onUrlClick = onUrlClick, onContentClick = onContentClick, onUnavailableQuoteClick = onUnavailableQuoteClick, ) } } @Composable private fun BlogEmbedUi( modifier: Modifier, embed: BlogEmbed, style: StatusStyle, onContentClick: (Blog) -> Unit, onUrlClick: (url: String) -> Unit, onUnavailableQuoteClick: (String) -> Unit, ) { when (embed) { is BlogEmbed.Link -> { StatusEmbedLinkUi( modifier = modifier .embedBorder() .fillMaxWidth(), linkEmbed = embed, style = style.cardStyle, onCardClick = { onUrlClick(embed.url) }, ) } is BlogEmbed.Blog -> { BlogInEmbedding( modifier = modifier .embedBorder() .padding(8.dp), blog = embed.blog, style = style, onContentClick = onContentClick, ) } is BlogEmbed.UnavailableQuote -> { UnavailableQuoteInEmbedding( modifier = modifier.embedBorder().padding(16.dp), unavailableQuote = embed, onContentClick = onUnavailableQuoteClick, ) } } } @Composable fun Modifier.embedBorder(): Modifier { return this.border( width = 1.dp, color = DividerDefaults.color, shape = RoundedCornerShape(8.dp), ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/embed/BlogInEmbedding.kt ================================================ package com.zhangke.fread.status.ui.embed import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.noRippleClick import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.blog.BlogEmbed import com.zhangke.fread.status.ui.BlogAuthorAvatar import com.zhangke.fread.status.ui.BlogTextContentSection import com.zhangke.fread.status.ui.media.BlogMedias import com.zhangke.fread.status.ui.publish.NameAndAccountInfo import com.zhangke.fread.status.ui.style.StatusStyle @Composable fun BlogInEmbedding( modifier: Modifier, blog: Blog, style: StatusStyle, onContentClick: (Blog) -> Unit = {}, ) { Row( modifier = modifier.noRippleClick { onContentClick(blog) }, ) { BlogAuthorAvatar( modifier = Modifier.size(style.infoLineStyle.avatarSize), imageUrl = blog.author.avatar, ) Column( modifier = Modifier.weight(1F).padding(start = 8.dp), ) { // Name\handle\time row Row( modifier = Modifier.fillMaxWidth().padding(top = 2.dp), ) { NameAndAccountInfo( modifier = Modifier.weight(1F) .alignByBaseline(), humanizedName = blog.author.humanizedName, handle = blog.author.prettyHandle, style = style, ) Text( modifier = Modifier.padding(start = 4.dp) .alignByBaseline(), text = blog.formattingDisplayTime.formattedTime(), style = style.infoLineStyle.descStyle, color = style.secondaryFontColor, ) } // text content and images Row( modifier = Modifier.fillMaxWidth().padding(top = 2.dp), ) { Column(modifier = Modifier.weight(3F)) { BlogTextContentSection( blog = blog, style = style.contentStyle.copy( maxLine = 10 ), ) } if (blog.mediaList.isNotEmpty()) { Spacer(modifier = Modifier.size(8.dp)) BlogMedias( modifier = Modifier.weight(1F), mediaList = blog.mediaList, sharedElementId = blog.id, indexInList = 0, sensitive = blog.sensitive, onMediaClick = {}, showAlt = false, ) } } // link card val linkEmbed = blog.embeds.firstNotNullOfOrNull { it as? BlogEmbed.Link } if (linkEmbed != null) { Spacer(modifier = Modifier.height(style.contentStyle.contentVerticalSpacing)) StatusEmbedLinkUi( modifier = Modifier.fillMaxWidth() .embedBorder(), style = style.cardStyle, linkEmbed = linkEmbed, onCardClick = {}, ) } } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/embed/StatusEmbedLinkUi.kt ================================================ package com.zhangke.fread.status.ui.embed import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.seiko.imageloader.model.ImageRequest import com.seiko.imageloader.ui.AutoSizeImage import com.zhangke.framework.blurhash.blurhash import com.zhangke.fread.status.blog.BlogEmbed import com.zhangke.fread.status.ui.style.StatusStyle @Composable fun StatusEmbedLinkUi( modifier: Modifier, linkEmbed: BlogEmbed.Link, style: StatusStyle.CardStyle, onCardClick: (BlogEmbed.Link) -> Unit, ) { val containerModifier = modifier .clickable { onCardClick(linkEmbed) } .padding(bottom = style.contentVerticalPadding) if (linkEmbed.image.isNullOrEmpty().not()) { Column(modifier = containerModifier) { if (linkEmbed.image.isNullOrEmpty().not()) { Box( modifier = Modifier .fillMaxWidth() .aspectRatio(linkEmbed.aspectRatio) ) { AutoSizeImage( remember(linkEmbed.image) { ImageRequest(linkEmbed.image.orEmpty()) }, modifier = Modifier .clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) .fillMaxSize() .blurhash(linkEmbed.blurhash), contentScale = ContentScale.Crop, contentDescription = "Preview Image", ) if (linkEmbed.video) { IconButton( modifier = Modifier .size(32.dp) .align(Alignment.Center), onClick = { onCardClick(linkEmbed) }, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ), ) { Icon( imageVector = Icons.Default.PlayArrow, contentDescription = "Play Video", ) } } } } Spacer(modifier = Modifier.height(style.imageBottomPadding)) PreviewCardTexts(linkEmbed, style, 2) } } else { Row( modifier = containerModifier .height(86.dp) .padding(top = style.contentVerticalPadding), ) { Column( modifier = Modifier .weight(1F) .padding(end = 8.dp) ) { PreviewCardTexts(linkEmbed, style, 1) } Box( modifier = Modifier .padding(end = 10.dp) .fillMaxHeight() .aspectRatio(1F) .background( color = MaterialTheme.colorScheme.surfaceDim, shape = RoundedCornerShape(8.dp), ), ) { Icon( modifier = Modifier .align(Alignment.Center) .size(36.dp), imageVector = Icons.Default.Description, contentDescription = "Link", tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7F), ) } } } } @Composable private fun PreviewCardTexts( card: BlogEmbed.Link, style: StatusStyle.CardStyle, maxLine: Int, ) { if (!card.providerName.isNullOrEmpty()) { Text( modifier = Modifier.padding(horizontal = 16.dp), text = card.providerName!!, style = style.descStyle, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.height(4.dp)) } Text( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), textAlign = TextAlign.Start, text = card.title, style = style.titleStyle, maxLines = maxLine, overflow = TextOverflow.Ellipsis, ) if (card.description.isNotEmpty()) { Spacer(modifier = Modifier.height(4.dp)) Text( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), textAlign = TextAlign.Start, text = card.description, style = style.descStyle, maxLines = maxLine, overflow = TextOverflow.Ellipsis, ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/embed/UnavailableQuoteInEmbedding.kt ================================================ package com.zhangke.fread.status.ui.embed import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.blog.BlogEmbed import org.jetbrains.compose.resources.stringResource @Composable fun UnavailableQuoteInEmbedding( modifier: Modifier, unavailableQuote: BlogEmbed.UnavailableQuote, onContentClick: (String) -> Unit, ) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Card( modifier = Modifier.fillMaxWidth(), onClick = { if (!unavailableQuote.blogId.isNullOrEmpty()) { onContentClick(unavailableQuote.blogId!!) } }, enabled = !unavailableQuote.blogId.isNullOrEmpty(), ) { Box( modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center, ) { Text( text = stringResource(LocalizedString.status_ui_embed_quote_unavailable), ) } } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/hashtag/HashtagUi.kt ================================================ package com.zhangke.fread.status.ui.hashtag import androidx.compose.foundation.clickable 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.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.material3.DividerDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.SolidColor import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.zhangke.framework.composable.BezierCurve import com.zhangke.framework.composable.BezierCurveStyle import com.zhangke.framework.composable.freadPlaceholder import com.zhangke.framework.composable.textString import com.zhangke.framework.utils.toPx import com.zhangke.fread.status.model.Hashtag @Composable fun HashtagUi( tag: Hashtag, onClick: (Hashtag) -> Unit, modifier: Modifier = Modifier, ) { Row( modifier = modifier .fillMaxWidth() .heightIn(min = 80.dp) .clickable { onClick(tag) }, verticalAlignment = Alignment.CenterVertically, ) { Column( modifier = Modifier .weight(1F) .fillMaxWidth() .padding(start = 15.dp, end = 15.dp) ) { Text( text = tag.name, fontSize = 16.sp, ) Text( modifier = Modifier.padding(top = 3.dp), text = textString(tag.description), fontSize = 12.sp, ) } BezierCurve( modifier = Modifier .padding(end = 15.dp) .size(width = 70.dp, height = 40.dp), points = tag.history.history.reversed(), minPoint = tag.history.min, maxPoint = tag.history.max, style = BezierCurveStyle.StrokeAndFill( fillBrush = SolidColor(MaterialTheme.colorScheme.primary), strokeBrush = SolidColor(Color.White), stroke = Stroke(width = 1.dp.toPx()), ) ) } } @Composable fun HashtagUiPlaceholder() { Row( modifier = Modifier .fillMaxWidth() .heightIn(min = 80.dp), verticalAlignment = Alignment.CenterVertically, ) { Column( modifier = Modifier .weight(1F) .fillMaxWidth() .padding(start = 15.dp, end = 15.dp) ) { Box( modifier = Modifier .size(width = 100.dp, height = 16.dp) .freadPlaceholder(true) ) Spacer(modifier = Modifier.height(4.dp)) Box( modifier = Modifier .size(width = 200.dp, height = 16.dp) .freadPlaceholder(true) ) } BezierCurve( modifier = Modifier .padding(end = 15.dp) .size(width = 70.dp, height = 40.dp), points = listOf(0.4F, 0.5F, 0.4F, 0.5F, 0.6F, 0.7F), minPoint = 0.3F, maxPoint = 0.7F, style = BezierCurveStyle.StrokeAndFill( fillBrush = SolidColor(DividerDefaults.color), strokeBrush = SolidColor(DividerDefaults.color), stroke = Stroke(width = 1.dp.toPx()), ) ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/image/BlogImageMedia.kt ================================================ package com.zhangke.fread.status.ui.image import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayCircleOutline import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.seiko.imageloader.LocalImageLoader import com.seiko.imageloader.model.ImageRequest import com.seiko.imageloader.ui.AutoSizeImage import com.zhangke.framework.blurhash.blurhash import com.zhangke.framework.composable.rememberTransientModalBottomSheetState import com.zhangke.framework.imageloader.executeSafety import com.zhangke.framework.ktx.ifNullOrEmpty import com.zhangke.framework.nav.sharedElement import com.zhangke.fread.status.blog.BlogMedia import com.zhangke.fread.status.blog.BlogMediaMeta import com.zhangke.fread.status.blog.BlogMediaType import com.zhangke.fread.status.ui.common.LocalStatusSharedElementConfig import com.zhangke.fread.status.ui.common.StatusSharedElementConfig typealias OnBlogMediaClick = (BlogMediaClickEvent) -> Unit sealed interface BlogMediaClickEvent { data class BlogImageClickEvent( val index: Int, val mediaList: List, ) : BlogMediaClickEvent data class BlogVideoClickEvent( val index: Int, val media: BlogMedia, ) : BlogMediaClickEvent } data class ClickedBlogMedia( val media: BlogMedia, val sharedElementKey: String, ) /** * Image and Gifv */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun BlogImageMedias( mediaList: List, sharedElementId: String, containerWidth: Dp, hideContent: Boolean, style: BlogImageMediaStyle = BlogImageMediaDefault.defaultStyle, onMediaClick: OnBlogMediaClick, showAlt: Boolean = true, ) { val sharedElementConfig = LocalStatusSharedElementConfig.current val fixedMediaList = remember(mediaList, sharedElementId, sharedElementConfig) { mediaList.map { ClickedBlogMedia( media = it, sharedElementKey = buildSharedElementKey( config = sharedElementConfig, sharedElementId = sharedElementId, media = it, ), ) } } val aspectList = mediaList.take(6).map { it.meta.decideAspect(style.defaultMediaAspect) } BlogImageLayout( modifier = Modifier.clip(RoundedCornerShape(style.radius)), containerWidth = containerWidth, aspectList = aspectList, style = style, itemContent = { index -> val media = fixedMediaList[index] BlogImage( modifier = Modifier .fillMaxSize() .clickable(enabled = !hideContent) { onMediaClick( BlogMediaClickEvent.BlogImageClickEvent( index = index, mediaList = fixedMediaList, ) ) }, clickableMedia = media, hideContent = hideContent, showAlt = showAlt, ) } ) } private fun buildSharedElementKey( config: StatusSharedElementConfig, sharedElementId: String, media: BlogMedia, ): String { return config.buildImageKey("$sharedElementId-${media.url}") } /** * For Image or gif */ @Composable internal fun BlogImageLayout( modifier: Modifier, containerWidth: Dp, aspectList: List, style: BlogImageMediaStyle, itemContent: @Composable (index: Int) -> Unit, ) { when (aspectList.size) { 1 -> SingleBlogImageLayout( modifier = modifier, style = style, aspect = aspectList.first(), itemContent = { itemContent(0) }, ) 2 -> DoubleBlogImageLayout( modifier = modifier, style = style, aspectList = aspectList, itemContent = itemContent, ) 3 -> TripleImageMediaLayout( modifier = modifier, containerWidth = containerWidth, style = style, aspectList = aspectList, itemContent = itemContent, ) 4 -> QuadrupleImageMediaLayout( modifier = modifier, containerWidth = containerWidth, aspectList = aspectList, style = style, itemContent = itemContent, ) 5 -> FivefoldImageMediaLayout( modifier = modifier, containerWidth = containerWidth, aspectList = aspectList, style = style, itemContent = itemContent, ) 6 -> SixfoldImageMediaLayout( modifier = modifier, aspectList = aspectList, style = style, itemContent = itemContent, ) } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable internal fun BlogImage( modifier: Modifier, clickableMedia: ClickedBlogMedia, hideContent: Boolean, showAlt: Boolean, ) { val media = clickableMedia.media val imageUrl = if (media.type == BlogMediaType.GIFV) media.previewUrl else media.url if (hideContent) { val imageLoader = LocalImageLoader.current LaunchedEffect(media) { imageLoader.executeSafety( ImageRequest { data(imageUrl) } ) } } Box(modifier = modifier.blurhash(media.blurhash)) { if (!hideContent) { BlogAutoSizeImage( modifier = Modifier, imageUrl = imageUrl, description = media.description, sharedElementKey = clickableMedia.sharedElementKey, ) } if (media.type == BlogMediaType.GIFV) { Icon( modifier = Modifier .size(32.dp) .align(Alignment.Center), imageVector = Icons.Default.PlayCircleOutline, tint = Color.White, contentDescription = "Play", ) } if (showAlt && !media.description.isNullOrEmpty()) { var showBottomSheet by remember { mutableStateOf(false) } Surface( modifier = Modifier.align(Alignment.BottomStart) .padding(start = 2.dp, bottom = 2.dp), onClick = { showBottomSheet = true }, shape = RoundedCornerShape(4.dp), enabled = true, color = Color.Black.copy(alpha = 0.6F), contentColor = Color.White, content = { Text( modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), text = "ALT", style = MaterialTheme.typography.labelSmall, ) }, ) if (showBottomSheet) { val sheetState = rememberTransientModalBottomSheetState() ModalBottomSheet( sheetState = sheetState, onDismissRequest = { showBottomSheet = false }, ) { Box( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 28.dp) .wrapContentHeight() ) { SelectionContainer { Text(text = media.description.orEmpty()) } } } } } } } @Composable private fun BlogAutoSizeImage( modifier: Modifier, imageUrl: String?, description: String?, sharedElementKey: String, ) { val sharedElementConfig = LocalStatusSharedElementConfig.current val finalModifier = modifier.then( if (sharedElementConfig.imageAttachmentEnabled) { Modifier.sharedElement(sharedElementKey) } else { Modifier } ) AutoSizeImage( request = remember { ImageRequest { data(imageUrl) } }, modifier = finalModifier.fillMaxSize(), contentScale = ContentScale.Crop, contentDescription = description.ifNullOrEmpty { "Blog Image Media" }, ) } @Composable internal fun VerticalSpacer(height: Dp) { Spacer( modifier = Modifier .width(1.dp) .height(height) ) } @Composable internal fun HorizontalSpacer(width: Dp) { Spacer( modifier = Modifier .width(width) .height(1.dp) ) } internal fun BlogMediaMeta?.decideAspect(defaultMediaAspect: Float): Float { val metaAspect = when (this) { is BlogMediaMeta.ImageMeta -> this.original?.aspect is BlogMediaMeta.GifvMeta -> this.aspect ?: this.original?.aspect is BlogMediaMeta.VideoMeta -> this.aspect ?: this.original?.aspect else -> null } return metaAspect ?: defaultMediaAspect } data class BlogImageMediaStyle( val radius: Dp, val horizontalDivider: Dp, val verticalDivider: Dp, val defaultMediaAspect: Float, val minAspect: Float, val maxAspect: Float, val maxWeightInHorizontal: Float, val minWeightInHorizontal: Float, val maxWeightInHorizontalThreshold: Float, val quadrupleHorizontalThreshold: Float, val quadrupleVerticalThreshold: Float, val sixfoldAspect: Float, ) object BlogImageMediaDefault { val defaultStyle = BlogImageMediaStyle( radius = 8.dp, horizontalDivider = 4.dp, verticalDivider = 4.dp, defaultMediaAspect = 1F, minAspect = 0.6F, maxAspect = 3F, maxWeightInHorizontal = 0.75F, minWeightInHorizontal = 0.5F, maxWeightInHorizontalThreshold = 0.6F, quadrupleHorizontalThreshold = 0.67F, quadrupleVerticalThreshold = 1.5F, sixfoldAspect = 1.5F, ) } internal fun BlogImageMediaStyle.getCompliantAspect(aspect: Float): Float { return aspect.coerceAtLeast(minAspect).coerceAtMost(maxAspect) } /** * Image more than two, and is horizontal arrange. */ internal fun BlogImageMediaStyle.decideFirstImageWeightInHorizontalMode(aspect: Float): Float { if (aspect >= 1F) { return minWeightInHorizontal } if (aspect <= maxWeightInHorizontalThreshold) return maxWeightInHorizontal val floatingWeight = maxWeightInHorizontal - minWeightInHorizontal val factor = 1F / aspect - 1F val weight = minWeightInHorizontal + floatingWeight * factor return weight.coerceAtLeast(minWeightInHorizontal).coerceAtMost(maxWeightInHorizontal) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/image/DoubleBlogImageLayout.kt ================================================ package com.zhangke.fread.status.ui.image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.zhangke.framework.ktx.second @Composable internal fun DoubleBlogImageLayout( modifier: Modifier = Modifier, style: BlogImageMediaStyle, aspectList: List, itemContent: @Composable (index: Int) -> Unit, ) { val firstAspect = aspectList.first() val secondAspect = aspectList.second() val firstFixedAspect = style.getCompliantAspect(firstAspect) val secondFixedAspect = style.getCompliantAspect(secondAspect) if (firstAspect > 1 && secondAspect > 1) { // vertical arrange Column( modifier = modifier .fillMaxWidth() ) { Box( modifier = Modifier .fillMaxWidth() .aspectRatio(firstFixedAspect) ) { itemContent(0) } VerticalSpacer(style.verticalDivider) Box( modifier = Modifier .fillMaxWidth() .aspectRatio(secondFixedAspect) ) { itemContent(1) } } } else { // horizontal arrange Row( modifier = modifier .fillMaxWidth() ) { Box( modifier = Modifier .weight(firstFixedAspect) .aspectRatio(firstFixedAspect) ) { itemContent(0) } HorizontalSpacer(style.horizontalDivider) Box( modifier = Modifier .weight(secondFixedAspect) .aspectRatio(secondFixedAspect) ) { itemContent(1) } } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/image/FivefoldImageMediaFrameLayout.kt ================================================ package com.zhangke.fread.status.ui.image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize 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.Modifier import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import com.zhangke.framework.ktx.averageDropFirst import com.zhangke.framework.ktx.second import com.zhangke.framework.ktx.third import com.zhangke.framework.utils.pxToDp @Composable internal fun FivefoldImageMediaLayout( modifier: Modifier, containerWidth: Dp, aspectList: List, style: BlogImageMediaStyle, itemContent: @Composable (index: Int) -> Unit, ) { val density = LocalDensity.current val firstAspect = aspectList.first() if (firstAspect < 1F) { HorizontalImageMediaFrameLayout( modifier = modifier, containerWidth = containerWidth, style = style, startAspect = firstAspect, startContent = { var mainlyWidth: Dp? by remember { mutableStateOf(null) } Box( modifier = Modifier .fillMaxSize() .onGloballyPositioned { mainlyWidth = it.size.width.pxToDp(density) } ) { if (mainlyWidth != null) { val verticalList = remember { mutableListOf().apply { add(aspectList.first()) add(aspectList[3]) add(aspectList[4]) } } VerticalImageMediaFrameLayout( modifier = Modifier, containerWidth = mainlyWidth!!, style = style, topAspect = firstAspect, fixedHeight = true, bottomAspect = verticalList.averageDropFirst(1).toFloat(), topContent = { itemContent(0) }, bottomContent = { HorizontalImageMediaListLayout( modifier = Modifier.fillMaxSize(), style = style, dropFirst = 1, aspectList = verticalList, itemContent = { index -> itemContent(2 + index) } ) }, ) } } }, endContent = { VerticalImageMediaListLayout( modifier = Modifier.fillMaxSize(), style = style, dropFirst = 1, aspectList = aspectList.take(3), itemContent = itemContent, ) }, ) } else { val bottomAspectList = aspectList.drop(3) VerticalImageMediaFrameLayout( modifier = modifier, containerWidth = containerWidth, style = style, topAspect = aspectList.first(), bottomAspect = bottomAspectList.average().toFloat(), topContent = { var mainlyWidth: Dp? by remember { mutableStateOf(null) } Box( modifier = Modifier .fillMaxSize() .onGloballyPositioned { mainlyWidth = it.size.width.pxToDp(density) } ) { if (mainlyWidth != null) { HorizontalImageMediaFrameLayout( modifier = Modifier, containerWidth = mainlyWidth!!, style = style, fixedHeight = true, startAspect = aspectList.first(), startContent = { itemContent(0) }, endContent = { VerticalImageMediaListLayout( modifier = Modifier.fillMaxSize(), style = style, dropFirst = 0, aspectList = listOf(aspectList.second(), aspectList.third()), itemContent = { index -> itemContent(1 + index) } ) } ) } } }, bottomContent = { HorizontalImageMediaListLayout( modifier = Modifier.fillMaxSize(), style = style, dropFirst = 0, aspectList = bottomAspectList, itemContent = { index -> itemContent(3 + index) } ) }, ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/image/HorizontalImageMediaFrameLayout.kt ================================================ package com.zhangke.fread.status.ui.image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp /** * As shown below: * --------------- -------- * | | | | * | | | | * | | | | * | | | | * | | | | * | | | | * | | | | * | | | | * --------------- --------- */ @Composable internal fun HorizontalImageMediaFrameLayout( modifier: Modifier, containerWidth: Dp, fixedHeight: Boolean = false, style: BlogImageMediaStyle, startAspect: Float, startContent: @Composable () -> Unit, endContent: @Composable () -> Unit, ) { val startFixedAspect = style.getCompliantAspect(startAspect) val firstImageWidthWeight = style.decideFirstImageWeightInHorizontalMode(startFixedAspect) val remainderContainerWidth = containerWidth - style.horizontalDivider val firstImageWidth = remainderContainerWidth * firstImageWidthWeight val finalModifier = if (fixedHeight) { modifier.fillMaxHeight() } else { val firstImageHeight = firstImageWidth / startFixedAspect modifier.height(firstImageHeight) } Box( modifier = finalModifier .fillMaxWidth() ) { Box( modifier = Modifier .width(firstImageWidth) .fillMaxHeight() ) { startContent() } Box( modifier = Modifier .align(Alignment.CenterEnd) .width(remainderContainerWidth - firstImageWidth) .fillMaxHeight() ) { endContent() } } } /** * As shown below: * --------------- -------- * | | | | * | | | | * | | | | * | | -------- * | | -------- * | | | | * | | | | * | | | | * | | | | * --------------- --------- */ @Composable internal fun HorizontalImageMediaFrameLayout( modifier: Modifier, containerWidth: Dp, style: BlogImageMediaStyle, aspectList: List, itemContent: @Composable (index: Int) -> Unit, ) { HorizontalImageMediaFrameLayout( modifier = modifier, containerWidth = containerWidth, style = style, startAspect = aspectList.first(), startContent = { itemContent(0) }, endContent = { VerticalImageMediaListLayout( modifier = Modifier, dropFirst = 1, style = style, aspectList = aspectList, itemContent = itemContent, ) } ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/image/HorizontalImageMediaListLayout.kt ================================================ package com.zhangke.fread.status.ui.image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @Composable internal fun HorizontalImageMediaListLayout( modifier: Modifier, style: BlogImageMediaStyle, dropFirst: Int, aspectList: List, itemContent: @Composable (index: Int) -> Unit, ) { Row( modifier = modifier ) { for (index in dropFirst until aspectList.size) { Box( modifier = Modifier .fillMaxHeight() .weight(1F) ) { itemContent(index) } if (index < aspectList.lastIndex) { HorizontalSpacer(width = style.horizontalDivider) } } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/image/QuadrupleImageMediaLayout.kt ================================================ package com.zhangke.fread.status.ui.image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp @Composable internal fun QuadrupleImageMediaLayout( modifier: Modifier = Modifier, containerWidth: Dp, aspectList: List, style: BlogImageMediaStyle, itemContent: @Composable (index: Int) -> Unit, ) { val firstAspect = aspectList.first() if (firstAspect > style.quadrupleHorizontalThreshold && firstAspect < style.quadrupleVerticalThreshold ) { // Grid arrange QuadrupleGridLayout( modifier = modifier, aspectList = aspectList, style = style, itemContent = itemContent, ) } else if (firstAspect <= style.quadrupleHorizontalThreshold) { // horizontal arrange HorizontalImageMediaFrameLayout( modifier = modifier, containerWidth = containerWidth, style = style, aspectList = aspectList, itemContent = itemContent, ) } else { // vertical arrange VerticalImageMediaFrameLayout( modifier = modifier, containerWidth = containerWidth, style = style, aspectList = aspectList, itemContent = itemContent, ) } } @Composable private fun QuadrupleGridLayout( modifier: Modifier, aspectList: List, style: BlogImageMediaStyle, itemContent: @Composable (index: Int) -> Unit, ) { val firstAspect = aspectList.first() val fixedFirstAspect = style.getCompliantAspect(firstAspect) Column( modifier = modifier .fillMaxWidth() ) { Row(modifier = Modifier.fillMaxWidth()) { Box( modifier = Modifier .weight(1F) .aspectRatio(fixedFirstAspect) ) { itemContent(0) } HorizontalSpacer(width = style.horizontalDivider) Box( modifier = Modifier .weight(1F) .aspectRatio(fixedFirstAspect) ) { itemContent(1) } } VerticalSpacer(height = style.verticalDivider) Row(modifier = Modifier.fillMaxWidth()) { Box( modifier = Modifier .weight(1F) .aspectRatio(fixedFirstAspect) ) { itemContent(2) } HorizontalSpacer(width = style.horizontalDivider) Box( modifier = Modifier .weight(1F) .aspectRatio(fixedFirstAspect) ) { itemContent(3) } } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/image/SingleBlogImageLayout.kt ================================================ package com.zhangke.fread.status.ui.image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @Composable internal fun SingleBlogImageLayout( modifier: Modifier, style: BlogImageMediaStyle, aspect: Float, itemContent: @Composable () -> Unit, ) { val fixedAspect = style.getCompliantAspect(aspect) Box( modifier = modifier .fillMaxWidth() .aspectRatio(fixedAspect), contentAlignment = Alignment.Center, ) { itemContent() } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/image/SixfoldImageMediaLayout.kt ================================================ package com.zhangke.fread.status.ui.image import androidx.compose.foundation.layout.Box 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.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @Composable internal fun SixfoldImageMediaLayout( modifier: Modifier = Modifier, aspectList: List, style: BlogImageMediaStyle, itemContent: @Composable (index: Int) -> Unit, ) { Column( modifier = modifier .fillMaxWidth() .aspectRatio(style.sixfoldAspect) ) { HorizontalImageMediaList( modifier = Modifier .fillMaxWidth() .weight(1F), dropFirst = 0, aspectList = aspectList.take(3), style = style, itemContent = itemContent, ) VerticalSpacer(height = style.verticalDivider) HorizontalImageMediaList( modifier = Modifier .fillMaxWidth() .weight(1F), dropFirst = 3, aspectList = aspectList, style = style, itemContent = itemContent, ) } } @Composable private fun HorizontalImageMediaList( modifier: Modifier, dropFirst: Int, aspectList: List, style: BlogImageMediaStyle, itemContent: @Composable (index: Int) -> Unit, ) { Row(modifier = modifier) { for (index in dropFirst until aspectList.size) { Box( modifier = Modifier .fillMaxHeight() .weight(1F) ) { itemContent(index) } if (index < aspectList.lastIndex) { HorizontalSpacer(width = style.horizontalDivider) } } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/image/TripleImageMediaLayout.kt ================================================ package com.zhangke.fread.status.ui.image import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp @Composable internal fun TripleImageMediaLayout( modifier: Modifier = Modifier, containerWidth: Dp, style: BlogImageMediaStyle, aspectList: List, itemContent: @Composable (index: Int) -> Unit, ) { val firstAspect = aspectList.first() if (firstAspect <= 1F) { // horizontal arrange HorizontalImageMediaFrameLayout( modifier = modifier, containerWidth = containerWidth, style = style, aspectList = aspectList, itemContent = itemContent, ) } else { // vertical arrange VerticalImageMediaFrameLayout( modifier = modifier, containerWidth = containerWidth, style = style, aspectList = aspectList, itemContent = itemContent, ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/image/VerticalImageMediaFrameLayout.kt ================================================ package com.zhangke.fread.status.ui.image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.coerceAtMost import com.zhangke.framework.ktx.averageDropFirst /** * As shown below: * --------------------- * | | * | | * | | * | | * | | * | | * --------------------- * --------------------- * | | * --------------------- */ @Composable internal fun VerticalImageMediaFrameLayout( modifier: Modifier, containerWidth: Dp, style: BlogImageMediaStyle, topAspect: Float, bottomAspect: Float, fixedHeight: Boolean = false, topContent: @Composable () -> Unit, bottomContent: @Composable () -> Unit, ) { val fixedTopAspect = style.getCompliantAspect(topAspect) val fixedBottomAspect = style.getCompliantAspect(bottomAspect) val finalModifier = if (fixedHeight) { modifier.fillMaxHeight() } else { val minHeight = containerWidth / style.maxAspect val maxHeight = containerWidth / style.minAspect val bottomHeight = containerWidth / fixedBottomAspect val topHeight = containerWidth / fixedTopAspect val reckonTotalHeight = style.verticalDivider + bottomHeight + topHeight val totalHeight = reckonTotalHeight.coerceAtLeast(minHeight).coerceAtMost(maxHeight) modifier.height(totalHeight) } Column( modifier = finalModifier .fillMaxWidth(), ) { Box( modifier = Modifier .fillMaxWidth() .weight(1F / fixedTopAspect) ) { topContent() } VerticalSpacer(height = style.verticalDivider) Box( modifier = Modifier .fillMaxWidth() .weight(1F / fixedBottomAspect) ) { bottomContent() } } } /** * As shown below: * --------------------- * | | * | | * | | * | | * | | * | | * --------------------- * ---- ------ ----- * | | | | | | * ---- ------ ----- */ @Composable internal fun VerticalImageMediaFrameLayout( modifier: Modifier, containerWidth: Dp, style: BlogImageMediaStyle, aspectList: List, itemContent: @Composable (index: Int) -> Unit, ) { VerticalImageMediaFrameLayout( modifier = modifier, containerWidth = containerWidth, style = style, topAspect = aspectList.first(), bottomAspect = aspectList.averageDropFirst(1).toFloat(), topContent = { itemContent(0) }, bottomContent = { HorizontalImageMediaListLayout( modifier = Modifier, style = style, dropFirst = 1, aspectList = aspectList, itemContent = itemContent, ) } ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/image/VerticalImageMediaListLayout.kt ================================================ package com.zhangke.fread.status.ui.image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @Composable internal fun VerticalImageMediaListLayout( modifier: Modifier, style: BlogImageMediaStyle, dropFirst: Int, aspectList: List, itemContent: @Composable (index: Int) -> Unit, ) { Column(modifier = modifier) { for (index in dropFirst until aspectList.size) { Box( modifier = Modifier .fillMaxWidth() .weight(1F) ) { itemContent(index) } if (index < aspectList.lastIndex) { VerticalSpacer(height = style.verticalDivider) } } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/label/StatusBottomEditedLabel.kt ================================================ package com.zhangke.fread.status.ui.label import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.ui.style.StatusStyle import org.jetbrains.compose.resources.stringResource @Composable fun StatusBottomEditedLabel( modifier: Modifier, editedAt: String, style: StatusStyle, ) { Text( modifier = modifier, text = stringResource(LocalizedString.statusUiBottomLabelEditedAt, editedAt), textAlign = TextAlign.Start, color = style.secondaryFontColor, style = style.bottomLabelStyle.textStyle, ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/label/StatusBottomInteractionLabel.kt ================================================ package com.zhangke.fread.status.ui.label import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp import com.zhangke.framework.composable.noRippleClick import com.zhangke.framework.utils.formatToHumanReadable import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.ui.style.StatusStyle import org.jetbrains.compose.resources.stringResource @Composable fun StatusBottomInteractionLabel( modifier: Modifier, boostedCount: Long, favouritedCount: Long, style: StatusStyle, onBoostedClick: () -> Unit, onFavouritedClick: () -> Unit, ) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, ) { val boostedText = buildHighlightLabelText( highlight = boostedCount.formatToHumanReadable(), wholeText = stringResource( LocalizedString.statusUiInteractionLabelBoostedCount, boostedCount.formatToHumanReadable(), ), style = style.bottomLabelStyle.textStyle, highLightColor = MaterialTheme.colorScheme.onSurface, ) Text( modifier = Modifier.noRippleClick { onBoostedClick() }, text = boostedText, color = style.secondaryFontColor, style = style.bottomLabelStyle.textStyle, ) val favouritedText = buildHighlightLabelText( highlight = favouritedCount.formatToHumanReadable(), wholeText = stringResource( LocalizedString.statusUiInteractionLabelFavouritedCount, favouritedCount.formatToHumanReadable(), ), style = style.bottomLabelStyle.textStyle, highLightColor = MaterialTheme.colorScheme.onSurface, ) Text( modifier = Modifier .padding(start = 6.dp) .noRippleClick { onFavouritedClick() }, text = favouritedText, color = style.secondaryFontColor, style = style.bottomLabelStyle.textStyle, ) } } private fun buildHighlightLabelText( highlight: String, wholeText: String, style: TextStyle, highLightColor: Color, ): AnnotatedString { return buildAnnotatedString { append(wholeText) val startIndex = wholeText.indexOf(highlight) val endIndex = startIndex + highlight.length if (startIndex in 0.. Unit, ) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, ) { Text( text = specificTime, style = style.bottomLabelStyle.textStyle, color = style.secondaryFontColor, ) if (blog.application?.name.isNullOrEmpty().not()) { Text( modifier = Modifier.noRippleClick( enabled = !blog.application?.website.isNullOrEmpty() ) { onUrlClick(blog.application!!.website!!) }, color = style.secondaryFontColor, text = " • ${blog.application!!.name}", style = style.bottomLabelStyle.textStyle, ) } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/label/StatusTopLabel.kt ================================================ package com.zhangke.fread.status.ui.label import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AlternateEmail import androidx.compose.material.icons.filled.PushPin import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.layout.onSizeChanged import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.zhangke.framework.composable.noRippleClick import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.ui.richtext.FreadRichText import com.zhangke.fread.status.ui.style.StatusStyle import com.zhangke.fread.statusui.Res import com.zhangke.fread.statusui.ic_status_forward import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource @Composable fun StatusMentionOnlyLabel( modifier: Modifier, style: StatusStyle, ) { IconWithTextLabel( modifier = modifier, icon = Icons.Default.AlternateEmail, text = stringResource(LocalizedString.statusUiVisibilityMentionedOnly), style = style, color = MaterialTheme.colorScheme.primary, ) } @Composable fun ReblogTopLabel( author: BlogAuthor, style: StatusStyle, onAuthorClick: (BlogAuthor) -> Unit, ) { val startPadding = style.containerStartPadding + style.infoLineStyle.avatarSize - style.topLabelStyle.iconSize Row( modifier = Modifier .fillMaxWidth() .padding( start = startPadding, end = style.containerEndPadding, ) .noRippleClick { onAuthorClick(author) }, verticalAlignment = Alignment.CenterVertically, ) { Icon( modifier = Modifier.size(style.topLabelStyle.iconSize), imageVector = vectorResource(Res.drawable.ic_status_forward), contentDescription = null, tint = style.secondaryFontColor, ) FreadRichText( modifier = Modifier.padding(start = 6.dp), richText = author.humanizedName, maxLines = 1, onHashtagClick = {}, onMentionClick = {}, onUrlClick = {}, fontSize = style.topLabelStyle.textSize, color = style.secondaryFontColor, ) Text( modifier = Modifier.padding(start = 4.dp), text = stringResource(LocalizedString.status_ui_reposted), maxLines = 1, style = MaterialTheme.typography.bodySmall, fontSize = style.topLabelStyle.textSize, color = style.secondaryFontColor, ) } } @Composable fun StatusPinnedLabel( modifier: Modifier = Modifier, style: StatusStyle, ) { IconWithTextLabel( modifier = modifier, icon = Icons.Default.PushPin, text = stringResource(LocalizedString.statusUiLabelPinned), style = style, ) } @Composable fun ContinueThread( style: StatusStyle, onHeightChanged: (Int) -> Unit, ) { val startPadding = style.containerStartPadding + style.infoLineStyle.avatarSize + style.infoLineStyle.nameToAvatarSpacing Text( modifier = Modifier.padding( start = startPadding, end = style.containerEndPadding, ).onSizeChanged { onHeightChanged(it.height) }, maxLines = 1, text = stringResource(LocalizedString.statusUiTopLabelContinuedThread), color = style.secondaryFontColor, fontSize = style.topLabelStyle.textSize, textDecoration = TextDecoration.Underline, ) } @Composable private fun IconWithTextLabel( modifier: Modifier, icon: ImageVector, text: String, style: StatusStyle, color: Color = style.secondaryFontColor, ) { val startPadding = style.containerStartPadding + style.infoLineStyle.avatarSize - style.topLabelStyle.iconSize Row( modifier = modifier .fillMaxWidth() .padding( start = startPadding, end = style.containerEndPadding, ), verticalAlignment = Alignment.CenterVertically, ) { Icon( modifier = Modifier.size(style.topLabelStyle.iconSize), imageVector = icon, contentDescription = text, tint = color, ) Text( modifier = Modifier.padding(start = style.infoLineStyle.nameToAvatarSpacing), maxLines = 1, text = text, color = color, fontSize = style.topLabelStyle.textSize, ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/media/BlogMedias.kt ================================================ package com.zhangke.fread.status.ui.media import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable 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.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.zhangke.framework.utils.pxToDp import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.blog.BlogMedia import com.zhangke.fread.status.blog.BlogMediaType import com.zhangke.fread.status.model.BlogTranslationUiState import com.zhangke.fread.status.ui.image.BlogImageMedias import com.zhangke.fread.status.ui.image.BlogMediaClickEvent import com.zhangke.fread.status.ui.image.OnBlogMediaClick import com.zhangke.fread.status.ui.style.LocalStatusUiConfig import com.zhangke.fread.status.ui.video.BlogVideos import org.jetbrains.compose.resources.stringResource private var cachedContainerWidth: Dp? = null @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun BlogMedias( modifier: Modifier, mediaList: List, sharedElementId: String, indexInList: Int, sensitive: Boolean, onMediaClick: OnBlogMediaClick, showAlt: Boolean = true, blogTranslationState: BlogTranslationUiState? = null, ) { val density = LocalDensity.current var containerWidth: Dp? by remember { mutableStateOf(cachedContainerWidth) } val statusConfig = LocalStatusUiConfig.current var hideContent by rememberSaveable( sensitive, mediaList, statusConfig.alwaysShowSensitiveContent, ) { mutableStateOf(sensitive && !statusConfig.alwaysShowSensitiveContent) } Box( modifier = modifier .onGloballyPositioned { containerWidth = it.size.width.pxToDp(density) cachedContainerWidth = containerWidth } ) { if (containerWidth != null) { BlogMediaContent( mediaList = mediaList, sharedElementId = sharedElementId, blogTranslationState = blogTranslationState, hideContent = hideContent, indexInList = indexInList, containerWidth = containerWidth!!, onMediaClick = onMediaClick, showAlt = showAlt, ) } if (sensitive) { if (hideContent) { TextButton( modifier = Modifier.align(Alignment.Center), onClick = { hideContent = false }, colors = ButtonDefaults.textButtonColors( containerColor = Color.Black.copy(alpha = 0.5F), contentColor = Color.White, ), ) { Text(stringResource(LocalizedString.statusUiImageSensitiveLabel)) } } else { IconButton( onClick = { hideContent = true }, colors = IconButtonDefaults.iconButtonColors( containerColor = Color.Black.copy(alpha = 0.3F), contentColor = Color.White, ), ) { Icon( modifier = Modifier.padding(2.dp), imageVector = Icons.Default.VisibilityOff, contentDescription = "Hide Content", tint = Color.White, ) } } } } } @OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun BlogMediaContent( mediaList: List, sharedElementId: String, blogTranslationState: BlogTranslationUiState?, hideContent: Boolean, indexInList: Int, containerWidth: Dp, showAlt: Boolean, onMediaClick: OnBlogMediaClick, ) { if (mediaList.firstOrNull()?.type == BlogMediaType.VIDEO) { BlogVideos( mediaList = mediaList, indexInList = indexInList, hideContent = hideContent, onMediaClick = onMediaClick, ) } else { val imageMediaList = mediaList.filter { it.type == BlogMediaType.IMAGE || it.type == BlogMediaType.GIFV } BlogImageMedias( mediaList = imageMediaList, sharedElementId = sharedElementId, hideContent = hideContent, containerWidth = containerWidth, onMediaClick = { onMediaClick(it.transformTranslatedEvent(blogTranslationState)) }, showAlt = showAlt, ) } } private fun BlogMediaClickEvent.transformTranslatedEvent( blogTranslationState: BlogTranslationUiState?, ): BlogMediaClickEvent { val imageEvent = (this as? BlogMediaClickEvent.BlogImageClickEvent) ?: return this if (blogTranslationState == null) return this if (!blogTranslationState.showingTranslation) return this val attachment = blogTranslationState.blogTranslation?.attachments ?: return this val mediaList = imageEvent.mediaList if (attachment.size != mediaList.size) return this val newMediaList = mediaList.map { media -> val description = attachment.firstOrNull { it.id == media.media.id }?.description ?: media.media.description media.copy( media = media.media.copy(description = description), ) } return imageEvent.copy( mediaList = newMediaList, ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/placeholder/ListWithAvatarPlaceholder.kt ================================================ package com.zhangke.fread.status.ui.placeholder import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape 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.unit.dp import com.zhangke.framework.composable.freadPlaceholder @Composable fun TitleWithAvatarItemPlaceholder( modifier: Modifier, ) { Row( modifier = modifier.height(48.dp).padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier.size(36.dp) .clip(RoundedCornerShape(6.dp)) .freadPlaceholder(true), ) Spacer(modifier = Modifier.width(16.dp)) Box( modifier = Modifier.size(width = 180.dp, height = 18.dp) .clip(RoundedCornerShape(4.dp)) .freadPlaceholder(true), ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/poll/BlogPoll.kt ================================================ package com.zhangke.fread.status.ui.poll import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.blog.BlogPoll import com.zhangke.fread.status.model.BlogTranslationUiState import org.jetbrains.compose.resources.stringResource @Composable fun BlogPoll( modifier: Modifier, poll: BlogPoll, isSelf: Boolean?, blogTranslationState: BlogTranslationUiState, onVoted: (List) -> Unit, ) { // 显示投票占比,满足任意条件:发帖人、投票结束、已投票 Column(modifier = modifier) { if (poll.multiple) { MultipleChoicePoll(poll, isSelf == true, blogTranslationState, onVoted) } else { SingleChoicePoll(poll, isSelf == true, blogTranslationState, onVoted) } if (poll.expired) { val count = poll.votesCount val finishedTip = if (count <= 1) { stringResource(LocalizedString.statusUiPollVoteFinishedTip, count) } else { stringResource(LocalizedString.statusUiPollVotesFinishedTip, count) } Text( modifier = Modifier.padding(start = 4.dp, top = 8.dp), text = finishedTip, style = MaterialTheme.typography.labelMedium, ) } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/poll/BlogPollOption.kt ================================================ package com.zhangke.fread.status.ui.poll import androidx.annotation.FloatRange import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box 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.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.SlickRoundCornerShape import com.zhangke.framework.utils.pxToDp import kotlin.math.roundToInt @Composable internal fun BlogPollOption( modifier: Modifier, selected: Boolean, votable: Boolean, showProgress: Boolean, optionContent: String, @FloatRange(from = 0.0, to = 1.0) progress: Float, onClick: () -> Unit, ) { val density = LocalDensity.current val colorScheme = MaterialTheme.colorScheme var containerSize: IntSize? by remember { mutableStateOf(null) } val borderWidth = 1.dp val cornerRadius = 21.dp Box( modifier = modifier .onSizeChanged { if (it != containerSize) { containerSize = it } } .heightIn(min = 42.dp) .clip(RoundedCornerShape(cornerRadius)) .border( width = borderWidth, color = MaterialTheme.colorScheme.onSurfaceVariant, shape = RoundedCornerShape(cornerRadius), ) .clickable(votable) { onClick() }, ) { val fixedContainerSize = containerSize if (showProgress && fixedContainerSize != null && progress > 0F) { val progressWidth = fixedContainerSize.width * progress.coerceAtMost(1F) Box( modifier = Modifier .align(Alignment.CenterStart) .padding(start = borderWidth) .size( height = fixedContainerSize.height.pxToDp(density), width = progressWidth.pxToDp(density), ) .clip(SlickRoundCornerShape(cornerRadius)) .background(color = Color.Blue.copy(alpha = 0.3F)), ) } Row( modifier = Modifier .padding(start = 18.dp, end = 15.dp, top = 10.dp, bottom = 10.dp) .align(Alignment.CenterStart) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = Modifier.widthIn(max = 250.dp), text = optionContent, color = colorScheme.primary, ) if (selected) { Icon( modifier = Modifier .padding(start = 6.dp) .size(14.dp), painter = rememberVectorPainter(Icons.Default.Check), contentDescription = "", ) } } if (showProgress) { Text( modifier = Modifier .align(Alignment.CenterEnd) .padding(end = 20.dp), text = "${(progress * 100).roundToInt()} %", ) } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/poll/MultipleChoicePoll.kt ================================================ package com.zhangke.fread.status.ui.poll import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember 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.unit.dp import com.zhangke.framework.composable.SlickRoundCornerShape import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.BlogTranslationUiState import com.zhangke.fread.status.blog.BlogPoll import org.jetbrains.compose.resources.stringResource @Composable internal fun MultipleChoicePoll( poll: BlogPoll, isSelf: Boolean, blogTranslationState: BlogTranslationUiState, onVoted: (List) -> Unit, ) { val indexToSelected = remember(poll) { val map = mutableMapOf() poll.options.forEachIndexed { index, _ -> map[index] = poll.ownVotes.contains(index) } mutableStateMapOf(*map.map { it.key to it.value }.toTypedArray()) } val translatedPoll = blogTranslationState.blogTranslation?.poll val pollIsInVotable = !poll.expired && poll.voted == false && !isSelf val sum = poll.options.sumOf { it.votesCount ?: 0 }.toFloat() poll.options.forEachIndexed { index, option -> val votesCount = option.votesCount?.toFloat() ?: 0F val progress = if (votesCount > 0) votesCount / sum else 0F var optionContent: String = option.title if (blogTranslationState.showingTranslation) { translatedPoll?.options?.getOrNull(index)?.title?.let { optionContent = it } } val selected = indexToSelected[index] ?: false BlogPollOption( modifier = Modifier.fillMaxWidth(), optionContent = optionContent, selected = selected, votable = pollIsInVotable, showProgress = isSelf || selected || poll.expired, progress = progress, onClick = { indexToSelected[index] = indexToSelected[index]?.not() ?: false }, ) if (index < poll.options.lastIndex) { Spacer(modifier = Modifier.size(width = 1.dp, height = 10.dp)) } } if (pollIsInVotable) { val votable = indexToSelected.map { it.value }.contains(true) val backgroundColor = if (votable) { Color.Blue.copy(alpha = 0.6F) } else { Color.Gray.copy(alpha = 0.6F) } Box( modifier = Modifier .fillMaxWidth() .padding(top = 15.dp) .height(42.dp) .clip(SlickRoundCornerShape(21.dp)) .background(backgroundColor) .clickable(votable) { val votedOptions = indexToSelected .filter { it.value } .map { it.key } .map { poll.options[it] } onVoted(votedOptions) }, ) { Text( modifier = Modifier.align(Alignment.Center), text = stringResource(LocalizedString.statusUiPollVote), ) } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/poll/SingleChoicePoll.kt ================================================ package com.zhangke.fread.status.ui.poll import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.zhangke.fread.status.model.BlogTranslationUiState import com.zhangke.fread.status.blog.BlogPoll @Composable internal fun SingleChoicePoll( poll: BlogPoll, isSelf: Boolean, blogTranslationState: BlogTranslationUiState, onVoted: (List) -> Unit, ) { val sum = poll.options.sumOf { it.votesCount ?: 0 }.toFloat() val translatedPoll = blogTranslationState.blogTranslation?.poll val pollIsInVotable = !poll.expired && poll.voted == false && !isSelf poll.options.forEachIndexed { index, option -> val votesCount = option.votesCount?.toFloat() ?: 0F val progress = if (votesCount > 0) votesCount / sum else 0F val selected = poll.ownVotes.contains(index) val showProgress = isSelf || poll.expired || selected var optionContent: String = option.title if (blogTranslationState.showingTranslation) { translatedPoll?.options?.getOrNull(index)?.title?.let { optionContent = it } } BlogPollOption( modifier = Modifier.fillMaxWidth(), optionContent = optionContent, selected = selected, votable = pollIsInVotable, showProgress = showProgress, progress = progress, onClick = { onVoted(listOf(option)) }, ) if (index < poll.options.lastIndex) { Spacer(modifier = Modifier.size(width = 1.dp, height = 10.dp)) } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/publish/BlogInQuoting.kt ================================================ package com.zhangke.fread.status.ui.publish import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.unit.dp import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.ui.action.quoteIcon import com.zhangke.fread.status.ui.embed.BlogInEmbedding import com.zhangke.fread.status.ui.embed.embedBorder import com.zhangke.fread.status.ui.style.StatusStyle @Composable fun BlogInQuoting( modifier: Modifier, blog: Blog, style: StatusStyle, ) { Column( modifier = modifier, ) { Icon( modifier = Modifier.rotate(180F), imageVector = quoteIcon(), tint = MaterialTheme.colorScheme.outline, contentDescription = null, ) Spacer(Modifier.height(8.dp)) BlogInEmbedding( modifier = Modifier .embedBorder() .padding(8.dp), blog = blog, style = style, ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/publish/NameAndAccountInfo.kt ================================================ package com.zhangke.fread.status.ui.publish import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.TwoTextsInRow import com.zhangke.fread.status.richtext.RichText import com.zhangke.fread.status.ui.richtext.FreadRichText import com.zhangke.fread.status.ui.style.StatusStyle @Composable fun NameAndAccountInfo( modifier: Modifier, humanizedName: RichText, handle: String, style: StatusStyle, ) { TwoTextsInRow( firstText = { FreadRichText( modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, richText = humanizedName, onUrlClick = {}, fontWeight = FontWeight.SemiBold, fontSize = style.infoLineStyle.nameSize, ) }, secondText = { Text( modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, text = handle, color = style.secondaryFontColor, style = style.infoLineStyle.descStyle, ) }, spacing = 2.dp, modifier = modifier, ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/publish/PublishBlogStyle.kt ================================================ package com.zhangke.fread.status.ui.publish import androidx.compose.runtime.Composable import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.zhangke.fread.status.ui.style.StatusStyle import com.zhangke.fread.status.ui.style.StatusStyles data class PublishBlogStyle( val topPadding: Dp, val startPadding: Dp, val endPadding: Dp, val statusStyle: StatusStyle, ) object PublishBlogStyleDefault { @Composable fun defaultStyle(): PublishBlogStyle { val statusStyle = StatusStyles.medium() return PublishBlogStyle( topPadding = 24.dp, startPadding = 16.dp, endPadding = 16.dp, statusStyle = statusStyle, ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/richtext/FreadRichText.kt ================================================ package com.zhangke.fread.status.ui.richtext import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import com.seiko.imageloader.rememberImagePainter import com.zhangke.fread.status.model.Emoji import com.zhangke.fread.status.model.HashtagInStatus import com.zhangke.fread.status.model.Mention import com.zhangke.fread.status.richtext.RichText import com.zhangke.fread.status.richtext.buildRichText import com.zhangke.fread.status.richtext.model.RichLinkTarget @Composable fun FreadRichText( content: String, modifier: Modifier = Modifier, mentions: List = emptyList(), emojis: List = emptyList(), tags: List = emptyList(), onMentionClick: (Mention) -> Unit = {}, onHashtagClick: (HashtagInStatus) -> Unit = {}, onUrlClick: (url: String) -> Unit = {}, // layoutDirection: LayoutDirection = LocalLayoutDirection.current, overflow: TextOverflow = TextOverflow.Ellipsis, maxLines: Int = Int.MAX_VALUE, fontSize: TextUnit = TextUnit.Unspecified, fontStyle: FontStyle? = null, fontWeight: FontWeight? = null, textAlign: TextAlign? = null, ) { val richText = remember(content, mentions) { buildRichText( document = content, mentions = mentions, hashTags = tags, emojis = emojis, ) } FreadRichText( modifier = modifier, richText = richText, // layoutDirection = layoutDirection, overflow = overflow, maxLines = maxLines, fontStyle = fontStyle, fontWeight = fontWeight, textAlign = textAlign, onMentionClick = onMentionClick, onHashtagClick = onHashtagClick, fontSize = fontSize, onUrlClick = onUrlClick, ) } @Composable fun FreadRichText( modifier: Modifier, richText: RichText, color: Color = Color.Unspecified, fontStyle: FontStyle? = null, fontWeight: FontWeight? = null, lineHeight: TextUnit = 1.5.em, textAlign: TextAlign? = null, fontFamily: FontFamily? = null, letterSpacing: TextUnit = TextUnit.Unspecified, softWrap: Boolean = true, textDecoration: TextDecoration? = null, onMentionClick: (Mention) -> Unit = {}, onMentionDidClick: (String) -> Unit = {}, onHashtagClick: (HashtagInStatus) -> Unit = {}, onMaybeHashtagClick: (String) -> Unit = {}, onUrlClick: (url: String) -> Unit = {}, // layoutDirection: LayoutDirection = LocalLayoutDirection.current, overflow: TextOverflow = TextOverflow.Ellipsis, maxLines: Int = Int.MAX_VALUE, fontSize: TextUnit = TextUnit.Unspecified, onTextLayout: (TextLayoutResult) -> Unit = {}, style: TextStyle = LocalTextStyle.current ) { DisposableEffect(richText) { richText.onLinkTargetClick = { target -> when (target) { is RichLinkTarget.UrlTarget -> onUrlClick(target.url) is RichLinkTarget.MentionTarget -> onMentionClick(target.mention) is RichLinkTarget.MentionDidTarget -> onMentionDidClick(target.did) is RichLinkTarget.HashtagTarget -> onHashtagClick(target.hashtag) is RichLinkTarget.MaybeHashtagTarget -> onMaybeHashtagClick(target.hashtag) } } onDispose { richText.onLinkTargetClick = null } } Text( text = richText.parse(), modifier = modifier, color = color, overflow = overflow, maxLines = maxLines, fontStyle = fontStyle, lineHeight = lineHeight, fontWeight = fontWeight, textAlign = textAlign, fontSize = fontSize, fontFamily = fontFamily, letterSpacing = letterSpacing, softWrap = softWrap, textDecoration = textDecoration, onTextLayout = onTextLayout, style = style, inlineContent = rememberInlineContent(richText.emojis), ) } @Composable fun SelectableRichText( modifier: Modifier, richText: RichText, color: Color = Color.Unspecified, onMentionClick: (Mention) -> Unit = {}, onHashtagClick: (HashtagInStatus) -> Unit = {}, onMaybeHashtagClick: (String) -> Unit = {}, onUrlClick: (url: String) -> Unit = {}, // layoutDirection: LayoutDirection = LocalLayoutDirection.current, overflow: TextOverflow = TextOverflow.Ellipsis, maxLines: Int = Int.MAX_VALUE, fontSize: TextUnit = TextUnit.Unspecified, ) { Box(modifier = modifier) { SelectionContainer { FreadRichText( modifier = Modifier, richText = richText, color = color, onMentionClick = onMentionClick, onHashtagClick = onHashtagClick, onMaybeHashtagClick = onMaybeHashtagClick, onUrlClick = onUrlClick, overflow = overflow, maxLines = maxLines, fontSize = fontSize, ) } } } @Composable private fun rememberInlineContent( emojis: List, ): Map { return remember(emojis) { val emojiContent = InlineTextContent( placeholder = Placeholder( width = 1.5.em, height = 1.5.em, placeholderVerticalAlign = PlaceholderVerticalAlign.TextBottom, ), ) { shortCode -> val fixedShortCode = shortCode.removePrefix(":").removeSuffix(":") val emoji = emojis.firstOrNull { it.shortcode == fixedShortCode } if (emoji != null) { Image( painter = rememberImagePainter(emoji.url), contentDescription = null, modifier = Modifier.fillMaxSize(), ) } else { Text(fixedShortCode) } } mapOf("emoji" to emojiContent) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/richtext/RichTextWithIcon.kt ================================================ package com.zhangke.fread.status.ui.richtext import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.material3.LocalTextStyle 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.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import com.zhangke.fread.status.model.HashtagInStatus import com.zhangke.fread.status.model.Mention import com.zhangke.fread.status.richtext.RichText @Composable fun RichTextWithIcon( modifier: Modifier = Modifier, text: RichText, color: Color = Color.Unspecified, fontSize: TextUnit = TextUnit.Unspecified, fontStyle: FontStyle? = null, fontWeight: FontWeight? = null, fontFamily: FontFamily? = null, letterSpacing: TextUnit = TextUnit.Unspecified, textDecoration: TextDecoration? = null, textAlign: TextAlign? = null, lineHeight: TextUnit = TextUnit.Unspecified, overflow: TextOverflow = TextOverflow.Clip, softWrap: Boolean = true, maxLines: Int = Int.MAX_VALUE, startIcon: (@Composable () -> Unit)? = null, endIcon: (@Composable () -> Unit)? = null, onTextLayout: (TextLayoutResult) -> Unit = {}, onMentionClick: (Mention) -> Unit = {}, onMentionDidClick: (String) -> Unit = {}, onHashtagClick: (HashtagInStatus) -> Unit = {}, onMaybeHashtagClick: (String) -> Unit = {}, onUrlClick: (url: String) -> Unit = {}, style: TextStyle = LocalTextStyle.current, ) { Row( modifier = modifier, horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { startIcon?.invoke() FreadRichText( modifier = Modifier, richText = text, color = color, fontStyle = fontStyle, fontWeight = fontWeight, lineHeight = lineHeight, textAlign = textAlign, onMentionClick = onMentionClick, onMentionDidClick = onMentionDidClick, onHashtagClick = onHashtagClick, onMaybeHashtagClick = onMaybeHashtagClick, onUrlClick = onUrlClick, overflow = overflow, maxLines = maxLines, fontSize = fontSize, fontFamily = fontFamily, letterSpacing = letterSpacing, textDecoration = textDecoration, softWrap = softWrap, onTextLayout = onTextLayout, style = style, ) endIcon?.invoke() } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/source/BlogPlatformUi.kt ================================================ package com.zhangke.fread.status.ui.source import androidx.compose.foundation.Image 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.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale 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 com.seiko.imageloader.ui.AutoSizeImage import com.zhangke.fread.common.resources.logo import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.platform.PlatformSnapshot import com.zhangke.fread.status.ui.richtext.FreadRichText import com.zhangke.fread.statusui.Res import com.zhangke.fread.statusui.img_banner_background import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @Composable fun BlogPlatformCard( modifier: Modifier, platform: BlogPlatform, onLoginClick: (() -> Unit)?, ) { Box(modifier = modifier) { Card( modifier = Modifier.fillMaxWidth(), ) { Column( modifier = Modifier.fillMaxWidth(), ) { Box( modifier = Modifier.fillMaxWidth() .aspectRatio(2F) ) { Image( modifier = Modifier.fillMaxSize(), painter = painterResource(Res.drawable.img_banner_background), contentDescription = null, contentScale = ContentScale.Crop, ) AutoSizeImage( url = platform.thumbnail.orEmpty(), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, contentDescription = "banner", ) } Row( modifier = Modifier.padding(start = 16.dp, top = 6.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = platform.name, maxLines = 1, textAlign = TextAlign.Start, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleLarge, ) Spacer(modifier = Modifier.width(4.dp)) Image( modifier = Modifier.size(12.dp), imageVector = platform.protocol.logo, contentDescription = null, ) } Text( modifier = Modifier.padding( start = 16.dp, top = 2.dp, ), text = platform.baseUrl.host, maxLines = 1, textAlign = TextAlign.Start, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, ) FreadRichText( modifier = Modifier.fillMaxWidth() .padding( start = 16.dp, top = 2.dp, end = 16.dp, ), content = platform.description, maxLines = 3, ) Spacer(modifier = Modifier.height(16.dp)) if (onLoginClick != null) { Button( modifier = Modifier.padding(horizontal = 16.dp) .fillMaxWidth(), onClick = onLoginClick, ) { Text( text = stringResource(LocalizedString.login), ) } Spacer(modifier = Modifier.height(16.dp)) } } } } } @Composable fun BlogPlatformUi( modifier: Modifier, platform: BlogPlatform, showDivider: Boolean = true, ) { SourceCommonUi( modifier = modifier, thumbnail = platform.thumbnail.orEmpty(), title = platform.name, subtitle = platform.baseUrl.host, description = platform.description, protocolLogo = platform.protocol.logo, showDivider = showDivider, ) } @Composable fun BlogPlatformSnapshotUi( modifier: Modifier, platform: PlatformSnapshot, ) { SourceCommonUi( modifier = modifier, thumbnail = platform.thumbnail, title = platform.domain, subtitle = platform.domain, description = platform.description, protocolLogo = platform.protocol.logo, ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/source/SearchPlatformResultUi.kt ================================================ package com.zhangke.fread.status.ui.source import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.zhangke.fread.status.search.SearchedPlatform @Composable fun SearchPlatformResultUi( searchedResult: SearchedPlatform, onContentClick: (SearchedPlatform) -> Unit, ) { when (searchedResult) { is SearchedPlatform.Platform -> BlogPlatformUi( modifier = Modifier .fillMaxWidth() .clickable { onContentClick(searchedResult) }, platform = searchedResult.platform, ) is SearchedPlatform.Snapshot -> BlogPlatformSnapshotUi( modifier = Modifier .fillMaxWidth() .clickable { onContentClick(searchedResult) }, platform = searchedResult.snapshot, ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/source/SourceCommonUi.kt ================================================ package com.zhangke.fread.status.ui.source import androidx.compose.foundation.Image 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale 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 com.seiko.imageloader.model.ImageAction import com.seiko.imageloader.rememberImageActionPainter import com.seiko.imageloader.ui.AutoSizeBox import com.zhangke.framework.composable.freadPlaceholder import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.common.browser.launchWebTabInApp import com.zhangke.fread.status.ui.richtext.FreadRichText @Composable fun SourceCommonUi( thumbnail: String, title: String, subtitle: String?, description: String, protocolLogo: ImageVector?, modifier: Modifier = Modifier, showDivider: Boolean = true, ) { val browserLauncher = LocalActivityBrowserLauncher.current val coroutineScope = rememberCoroutineScope() Column(modifier = modifier) { Spacer(Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), ) { Spacer(Modifier.width(16.dp)) Box( modifier = Modifier.size(48.dp), ) { AutoSizeBox( url = thumbnail, modifier = Modifier.fillMaxSize(), ) { action -> Image( modifier = Modifier .freadPlaceholder( visible = action !is ImageAction.Success, shape = CircleShape, ) .matchParentSize() .clip(CircleShape), painter = rememberImageActionPainter(action), contentDescription = null, contentScale = ContentScale.Crop, ) } } Spacer(modifier = Modifier.width(8.dp)) Column { Row( verticalAlignment = Alignment.CenterVertically, ) { Text( text = title, maxLines = 1, textAlign = TextAlign.Start, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), ) if (protocolLogo != null) { Spacer(modifier = Modifier.width(4.dp)) Image( modifier = Modifier.size(12.dp), imageVector = protocolLogo, contentDescription = null, ) } } if (subtitle.isNullOrEmpty()) { Spacer(modifier = Modifier.height(0.5.dp)) } else { Spacer(modifier = Modifier.height(2.dp)) Text( text = subtitle, maxLines = 1, textAlign = TextAlign.Start, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelSmall, ) } Spacer(Modifier.height(2.dp)) FreadRichText( content = description, maxLines = 3, emojis = emptyList(), mentions = emptyList(), tags = emptyList(), onMentionClick = {}, onHashtagClick = {}, onUrlClick = { browserLauncher.launchWebTabInApp(coroutineScope, it) }, ) Spacer(modifier = Modifier.width(16.dp)) } } Spacer(Modifier.height(8.dp)) if (showDivider) { HorizontalDivider() } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/source/StatusSourceUi.kt ================================================ package com.zhangke.fread.status.ui.source import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.zhangke.fread.common.resources.logo import com.zhangke.fread.status.source.StatusSource @Composable fun StatusSourceUi( source: StatusSource, modifier: Modifier = Modifier, ) { SourceCommonUi( modifier = modifier, thumbnail = source.thumbnail.orEmpty(), title = source.name, subtitle = source.handle, description = source.description, protocolLogo = source.protocol.logo, ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/style/LocalStatusStyle.kt ================================================ package com.zhangke.fread.status.ui.style import androidx.compose.material3.DividerDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp object StatusStyles { @Composable fun small(): StatusStyle { return StatusStyle( containerStartPadding = 16.dp, containerTopPadding = 10.dp, containerEndPadding = 16.dp, containerBottomPadding = 10.dp, infolineToTopLabelPadding = 4.dp, topLabelStyle = smallTopLabelStyle(), infoLineStyle = smallInfoStyle(), contentStyle = smallContentStyle(), bottomPanelStyle = smallBottomPanelStyle(), threadsStyle = smallThreadsStyle(), cardStyle = smallCardStyle(), bottomLabelStyle = smallBottomLabelStyle(), ) } @Composable fun medium(): StatusStyle { return StatusStyle( containerStartPadding = 16.dp, containerTopPadding = 12.dp, containerEndPadding = 16.dp, containerBottomPadding = 12.dp, infolineToTopLabelPadding = 4.dp, topLabelStyle = mediumTopLabelStyle(), infoLineStyle = mediumInfoStyle(), contentStyle = mediumContentStyle(), bottomPanelStyle = mediumBottomPanelStyle(), threadsStyle = mediumThreadsStyle(), cardStyle = mediumCardStyle(), bottomLabelStyle = mediumBottomLabelStyle(), ) } @Composable fun large(): StatusStyle { return StatusStyle( containerStartPadding = 16.dp, containerTopPadding = 14.dp, containerEndPadding = 16.dp, containerBottomPadding = 14.dp, infolineToTopLabelPadding = 6.dp, topLabelStyle = largeTopLabelStyle(), infoLineStyle = largeInfoStyle(), contentStyle = largeContentStyle(), bottomPanelStyle = largeBottomPanelStyle(), threadsStyle = largeThreadsStyle(), cardStyle = largeCardStyle(), bottomLabelStyle = largeBottomLabelStyle(), ) } @Composable private fun smallThreadsStyle(): StatusStyle.ThreadsStyle { return mediumThreadsStyle() } @Composable private fun mediumThreadsStyle(): StatusStyle.ThreadsStyle { return StatusStyle.ThreadsStyle( lineWidth = 1.5.dp, color = DividerDefaults.color, ) } @Composable private fun largeThreadsStyle(): StatusStyle.ThreadsStyle { return mediumThreadsStyle() } @Composable private fun smallTopLabelStyle(): StatusStyle.TopLabelStyle { return StatusStyle.TopLabelStyle( iconSize = 12.dp, textSize = 11.sp, ) } @Composable private fun mediumTopLabelStyle(): StatusStyle.TopLabelStyle { return StatusStyle.TopLabelStyle( iconSize = 14.dp, textSize = 12.sp, ) } @Composable private fun largeTopLabelStyle(): StatusStyle.TopLabelStyle { return StatusStyle.TopLabelStyle( iconSize = 16.dp, textSize = 14.sp, ) } @Composable private fun smallInfoStyle(): StatusStyle.InfoLineStyle { return StatusStyle.InfoLineStyle( nameSize = 14.sp, avatarSize = 38.dp, nameToAvatarSpacing = 6.dp, descStyle = MaterialTheme.typography.bodySmall .copy(fontWeight = FontWeight.Light) ) } @Composable private fun mediumInfoStyle(): StatusStyle.InfoLineStyle { return StatusStyle.InfoLineStyle( nameSize = 16.sp, avatarSize = 42.dp, nameToAvatarSpacing = 8.dp, descStyle = MaterialTheme.typography.bodySmall .copy(fontWeight = FontWeight.Normal) ) } @Composable private fun largeInfoStyle(): StatusStyle.InfoLineStyle { return StatusStyle.InfoLineStyle( nameSize = 16.sp, avatarSize = 46.dp, nameToAvatarSpacing = 8.dp, descStyle = MaterialTheme.typography.bodyMedium .copy(fontWeight = FontWeight.Medium) ) } @Composable private fun smallContentStyle(): StatusStyle.ContentStyle { return StatusStyle.ContentStyle( maxLine = 10, titleSize = 14.sp, contentSize = 12.sp, startPadding = 0.dp, contentVerticalSpacing = 2.dp, ) } @Composable private fun mediumContentStyle(): StatusStyle.ContentStyle { return StatusStyle.ContentStyle( maxLine = 10, titleSize = 16.sp, contentSize = 14.sp, startPadding = 0.dp, contentVerticalSpacing = 4.dp, ) } @Composable private fun largeContentStyle(): StatusStyle.ContentStyle { return StatusStyle.ContentStyle( maxLine = 10, titleSize = 18.sp, contentSize = 16.sp, startPadding = 0.dp, contentVerticalSpacing = 6.dp, ) } @Composable private fun smallBottomPanelStyle(): StatusStyle.BottomPanelStyle { return StatusStyle.BottomPanelStyle( iconSize = 26.dp, startPadding = 0.dp ) } @Composable private fun mediumBottomPanelStyle(): StatusStyle.BottomPanelStyle { return StatusStyle.BottomPanelStyle( iconSize = 28.dp, startPadding = 0.dp ) } @Composable private fun largeBottomPanelStyle(): StatusStyle.BottomPanelStyle { return StatusStyle.BottomPanelStyle( iconSize = 30.dp, startPadding = 0.dp ) } @Composable private fun smallCardStyle(): StatusStyle.CardStyle { return StatusStyle.CardStyle( titleStyle = MaterialTheme.typography.titleSmall, descStyle = MaterialTheme.typography.bodySmall, imageBottomPadding = 6.dp, contentVerticalPadding = 6.dp ) } @Composable private fun mediumCardStyle(): StatusStyle.CardStyle { return StatusStyle.CardStyle( titleStyle = MaterialTheme.typography.titleMedium, descStyle = MaterialTheme.typography.bodyMedium, imageBottomPadding = 8.dp, contentVerticalPadding = 8.dp ) } @Composable private fun largeCardStyle(): StatusStyle.CardStyle { return StatusStyle.CardStyle( titleStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), descStyle = MaterialTheme.typography.bodyLarge, imageBottomPadding = 10.dp, contentVerticalPadding = 10.dp ) } @Composable private fun smallBottomLabelStyle(): StatusStyle.BottomLabelStyle { return StatusStyle.BottomLabelStyle( textStyle = MaterialTheme.typography.bodySmall .copy(fontSize = 11.sp), ) } @Composable private fun mediumBottomLabelStyle(): StatusStyle.BottomLabelStyle { return StatusStyle.BottomLabelStyle( textStyle = MaterialTheme.typography.bodyMedium .copy(fontSize = 12.sp), ) } @Composable private fun largeBottomLabelStyle(): StatusStyle.BottomLabelStyle { return StatusStyle.BottomLabelStyle( textStyle = MaterialTheme.typography.bodyLarge .copy(fontSize = 14.sp), ) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/style/StatusInfoStyle.kt ================================================ package com.zhangke.fread.status.ui.style import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp data class StatusInfoStyle( val avatarSize: Dp, val avatarToNamePadding: Dp, val nameToInfoLineSpacing: Dp, val descStyle: TextStyle, ) object StatusInfoStyleDefaults { val avatarSize = 40.dp val avatarToNamePadding = 8.dp val nameToInfoLineSpacing = 2.dp val descStyle: TextStyle @Composable get() = MaterialTheme.typography.bodySmall } @Composable fun defaultStatusInfoStyle() = StatusInfoStyle( avatarSize = StatusInfoStyleDefaults.avatarSize, avatarToNamePadding = StatusInfoStyleDefaults.avatarToNamePadding, nameToInfoLineSpacing = StatusInfoStyleDefaults.nameToInfoLineSpacing, descStyle = StatusInfoStyleDefaults.descStyle, ) ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/style/StatusStyle.kt ================================================ package com.zhangke.fread.status.ui.style import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit // 垂直方向的 padding 外面统一加 data class StatusStyle( val containerStartPadding: Dp, val containerTopPadding: Dp, val containerEndPadding: Dp, val containerBottomPadding: Dp, val topLabelStyle: TopLabelStyle, val infolineToTopLabelPadding: Dp, val infoLineStyle: InfoLineStyle, val contentStyle: ContentStyle, val bottomPanelStyle: BottomPanelStyle, val threadsStyle: ThreadsStyle, val cardStyle: CardStyle, val bottomLabelStyle: BottomLabelStyle, ) { val secondaryFontColor: Color @Composable get() = MaterialTheme.colorScheme.onSurfaceVariant fun contentIndentStyle(): StatusStyle { val contentStartPadding = infoLineStyle.avatarSize + infoLineStyle.nameToAvatarSpacing return copy( contentStyle = contentStyle.copy(startPadding = contentStartPadding), bottomPanelStyle = bottomPanelStyle.copy(startPadding = contentStartPadding), ) } data class TopLabelStyle( val iconSize: Dp, val textSize: TextUnit, ) data class ContentStyle( val maxLine: Int, val titleSize: TextUnit, val contentSize: TextUnit, val startPadding: Dp, /** * 内容部分竖向的间距,不包含顶部和底部,只包含info line to content * content to bottom panel 等。 */ val contentVerticalSpacing: Dp, ) data class InfoLineStyle( val nameSize: TextUnit, val avatarSize: Dp, val nameToAvatarSpacing: Dp, val descStyle: TextStyle, ) data class BottomPanelStyle( val iconSize: Dp, val startPadding: Dp, ) data class ThreadsStyle( val lineWidth: Dp, val color: Color, ) data class CardStyle( val titleStyle: TextStyle, val descStyle: TextStyle, val imageBottomPadding: Dp, val contentVerticalPadding: Dp, ) // for bottom label, like edited time, post time, application // liked count, reblog count, etc. data class BottomLabelStyle( val textStyle: TextStyle, ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/style/StatusUiConfig.kt ================================================ package com.zhangke.fread.status.ui.style import androidx.compose.runtime.Composable import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf import com.zhangke.fread.common.config.StatusConfig import com.zhangke.fread.common.config.StatusContentSize val LocalStatusUiConfig: ProvidableCompositionLocal = compositionLocalOf { error("LocalStatusUiConfig not init!") } data class StatusUiConfig( val alwaysShowSensitiveContent: Boolean, val contentStyle: StatusStyle, val immersiveNavBar: Boolean, ) { companion object { @Composable fun create( config: StatusConfig, ): StatusUiConfig { return StatusUiConfig( alwaysShowSensitiveContent = config.alwaysShowSensitiveContent, contentStyle = config.contentSize.toStyle(), immersiveNavBar = config.immersiveNavBar, ) } @Composable private fun StatusContentSize.toStyle(): StatusStyle { return when (this) { StatusContentSize.SMALL -> StatusStyles.small() StatusContentSize.MEDIUM -> StatusStyles.medium() StatusContentSize.LARGE -> StatusStyles.large() } } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/threads/Threads.kt ================================================ package com.zhangke.fread.status.ui.threads import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.zhangke.fread.status.ui.publish.PublishBlogStyle import com.zhangke.fread.status.ui.style.StatusStyle fun Modifier.threads( threadsType: ThreadsType, infoToTopSpacing: Float?, threadStyle: StatusStyle.ThreadsStyle, avatarSize: Dp, continueThreadLabelHeight: Int?, containerStartPadding: Dp, containerTopPadding: Dp, nameToAvatarSpacing: Dp, ): Modifier { if (threadsType == ThreadsType.NONE || threadsType == ThreadsType.UNSPECIFIED) return this infoToTopSpacing ?: return this return this.drawBehind { val containerTopPaddingPx = containerTopPadding.toPx() val strokeWidthPx = threadStyle.lineWidth.toPx() val threadPadding = 4.dp.toPx() if (threadsType == ThreadsType.CONTINUED_THREAD) { if (continueThreadLabelHeight != null) { val path = Path().apply { moveTo( x = containerStartPadding.toPx() + avatarSize.toPx() + nameToAvatarSpacing.toPx() - threadPadding, y = containerTopPaddingPx / 2 + continueThreadLabelHeight / 2, ) lineTo( x = containerStartPadding.toPx() + avatarSize.toPx() / 2, y = containerTopPaddingPx / 2 + continueThreadLabelHeight / 2, ) lineTo( x = containerStartPadding.toPx() + avatarSize.toPx() / 2, y = infoToTopSpacing - threadPadding, ) } drawPath( path = path, color = threadStyle.color, style = Stroke( width = strokeWidthPx, cap = StrokeCap.Round, pathEffect = PathEffect.cornerPathEffect(6.dp.toPx()), ), ) } } else { val startMargin = containerStartPadding + avatarSize / 2 val startMarginPx = startMargin.toPx() val firstLineY = infoToTopSpacing - threadPadding if (threadsType.drawTopOfAvatarLine) { drawLine( color = threadStyle.color, strokeWidth = strokeWidthPx, start = Offset(x = startMarginPx, y = 0F), end = Offset( x = startMarginPx, y = firstLineY, ), cap = StrokeCap.Round, ) } if (threadsType.drawBottomOfAvatarLine) { val lineOnBottomOfAvatarY = infoToTopSpacing + avatarSize.toPx() + threadPadding drawLine( color = threadStyle.color, start = Offset(x = startMarginPx, y = lineOnBottomOfAvatarY), end = Offset(x = startMarginPx, y = size.height), strokeWidth = strokeWidthPx, cap = StrokeCap.Round, ) } } } } fun Modifier.threads( threadsType: ThreadsType, infoToTopSpacing: Float?, style: StatusStyle, continueThreadLabelHeight: Int?, ): Modifier { return this.threads( threadsType = threadsType, infoToTopSpacing = infoToTopSpacing, avatarSize = style.infoLineStyle.avatarSize, threadStyle = style.threadsStyle, continueThreadLabelHeight = continueThreadLabelHeight, containerStartPadding = style.containerStartPadding, containerTopPadding = style.containerTopPadding, nameToAvatarSpacing = style.infoLineStyle.nameToAvatarSpacing, ) } fun Modifier.blogBeReplyThreads( threadsType: ThreadsType, publishBlogStyle: PublishBlogStyle, ): Modifier { return this.threads( threadsType = threadsType, infoToTopSpacing = 0F, threadStyle = publishBlogStyle.statusStyle.threadsStyle, containerTopPadding = 0.dp, continueThreadLabelHeight = null, containerStartPadding = publishBlogStyle.startPadding, avatarSize = publishBlogStyle.statusStyle.infoLineStyle.avatarSize, nameToAvatarSpacing = publishBlogStyle.statusStyle.infoLineStyle.nameToAvatarSpacing, ) } fun Modifier.blogInReplyingThreads( threadsType: ThreadsType, infoToTopSpacing: Float, publishBlogStyle: PublishBlogStyle, ): Modifier { return this.threads( threadsType = threadsType, infoToTopSpacing = infoToTopSpacing, continueThreadLabelHeight = null, threadStyle = publishBlogStyle.statusStyle.threadsStyle, containerTopPadding = publishBlogStyle.topPadding, containerStartPadding = publishBlogStyle.startPadding, avatarSize = publishBlogStyle.statusStyle.infoLineStyle.avatarSize, nameToAvatarSpacing = publishBlogStyle.statusStyle.infoLineStyle.nameToAvatarSpacing, ) } internal val ThreadsType.drawTopOfAvatarLine: Boolean get() = this == ThreadsType.ANCHOR || this == ThreadsType.ANCESTOR private val ThreadsType.drawBottomOfAvatarLine: Boolean get() = this == ThreadsType.FIRST_ANCESTOR || this == ThreadsType.ANCESTOR internal val ThreadsType.contentIndent: Boolean get() = this == ThreadsType.FIRST_ANCESTOR || this == ThreadsType.ANCESTOR ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/threads/ThreadsType.kt ================================================ package com.zhangke.fread.status.ui.threads enum class ThreadsType { UNSPECIFIED, NONE, /** * 第一个评论 */ FIRST_ANCESTOR, /** * 帖子上级评论 */ ANCESTOR, /** * 锚点帖子,且没有父级 */ ANCHOR_FIRST, /** * 锚点帖子 */ ANCHOR, /** * 在 Feeds 中的有父级的帖子 */ CONTINUED_THREAD, } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/update/AppUpdateDialog.kt ================================================ package com.zhangke.fread.status.ui.update import androidx.compose.runtime.Composable import androidx.compose.ui.window.DialogProperties import com.zhangke.framework.composable.FreadDialog import com.zhangke.fread.common.update.AppReleaseInfo import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.stringResource @Composable fun AppUpdateDialog( appReleaseInfo: AppReleaseInfo, onCancel: (AppReleaseInfo) -> Unit, onUpdateClick: (AppReleaseInfo) -> Unit, ) { FreadDialog( onDismissRequest = {}, properties = DialogProperties( dismissOnBackPress = false, dismissOnClickOutside = false, ), title = stringResource(LocalizedString.statusUiUpdateDialogTitle), contentText = stringResource( LocalizedString.statusUiUpdateDialogReleaseNote, appReleaseInfo.versionName, appReleaseInfo.releaseNote, ), onNegativeClick = { onCancel(appReleaseInfo) }, onPositiveClick = { onUpdateClick(appReleaseInfo) }, ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/user/CommonUserUi.kt ================================================ package com.zhangke.fread.status.ui.user import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.zhangke.framework.composable.freadPlaceholder import com.zhangke.fread.common.resources.logo import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.StatusProviderProtocol import com.zhangke.fread.status.richtext.RichText import com.zhangke.fread.status.ui.BlogAuthorAvatar import com.zhangke.fread.status.ui.richtext.FreadRichText @Composable fun CommonAccountUi( modifier: Modifier, account: LoggedAccount, showDivider: Boolean, ) { CommonProfileUi( modifier = modifier, avatar = account.avatar.orEmpty(), displayName = account.humanizedName, handle = account.prettyHandle, description = account.humanizedDescription, showDivider = showDivider, protocol = account.platform.protocol, showProtocolLabel = true, ) } @Composable fun BasicAccountUi( modifier: Modifier, account: LoggedAccount, ) { Row( modifier = modifier.fillMaxWidth() .padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { BlogAuthorAvatar( modifier = Modifier.size(42.dp), imageUrl = account.avatar, ) Column( modifier = Modifier.weight(1F) .padding(start = 8.dp), ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { FreadRichText( modifier = Modifier, richText = account.humanizedName, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 16.sp, ) Image( modifier = Modifier.padding(start = 4.dp).size(16.dp), painter = rememberVectorPainter(account.platform.protocol.logo), contentDescription = null, ) } Spacer(modifier = Modifier.height(2.dp)) Text( text = account.prettyHandle, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } @Composable fun CommonUserUi( user: BlogAuthor, modifier: Modifier = Modifier, actionButton: @Composable RowScope.() -> Unit = {}, showDivider: Boolean = true, ) { CommonProfileUi( modifier = modifier, avatar = user.avatar.orEmpty(), displayName = user.humanizedName, handle = user.prettyHandle, description = user.humanizedDescription, protocol = null, showProtocolLabel = false, showDivider = showDivider, actionButton = actionButton, ) } @Composable private fun CommonProfileUi( modifier: Modifier, avatar: String, displayName: RichText, handle: String, description: RichText, protocol: StatusProviderProtocol?, showProtocolLabel: Boolean, showDivider: Boolean, actionButton: @Composable RowScope.() -> Unit = {}, ) { Column(modifier = modifier.fillMaxWidth()) { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { BlogAuthorAvatar( modifier = Modifier.size(48.dp), imageUrl = avatar, ) Column( modifier = Modifier .padding(start = 16.dp) .align(Alignment.CenterVertically) .weight(1F), horizontalAlignment = Alignment.Start, ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { FreadRichText( modifier = Modifier, richText = displayName, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 16.sp, ) if (showProtocolLabel && protocol != null) { Image( modifier = Modifier.padding(start = 4.dp).size(16.dp), painter = rememberVectorPainter(protocol.logo), contentDescription = null, ) } } Spacer(modifier = Modifier.height(4.dp)) Text( text = handle, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(4.dp)) FreadRichText( modifier = Modifier.fillMaxWidth(), richText = description, maxLines = 6, ) } actionButton() } if (showDivider) { HorizontalDivider( thickness = 0.5.dp, ) } } } @Composable fun CommonUserPlaceHolder() { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { BlogAuthorAvatar( modifier = Modifier.size(48.dp), imageUrl = null, ) Column( modifier = Modifier .padding(start = 16.dp) .align(Alignment.CenterVertically) .weight(1F), horizontalAlignment = Alignment.Start, ) { Box( modifier = Modifier .width(100.dp) .height(18.dp) .freadPlaceholder(true) ) Spacer(modifier = Modifier.height(4.dp)) Box( modifier = Modifier .width(80.dp) .height(14.dp) .freadPlaceholder(true) ) Spacer(modifier = Modifier.height(4.dp)) Box( modifier = Modifier .fillMaxWidth() .height(16.dp) .freadPlaceholder(true) ) } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/user/UserHandleLine.kt ================================================ package com.zhangke.fread.status.ui.user import androidx.compose.foundation.background import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.SmartToy import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.stringResource @Composable fun UserHandleLine( modifier: Modifier, handle: String, bot: Boolean, followedBy: Boolean, ) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, ) { if (bot) { Icon( modifier = Modifier .padding(end = 4.dp) .size(16.dp), imageVector = Icons.Outlined.SmartToy, contentDescription = "Bot", tint = MaterialTheme.colorScheme.primary, ) } SelectionContainer { Text( modifier = Modifier, text = handle, maxLines = 1, color = MaterialTheme.colorScheme.onSurfaceVariant, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelMedium .copy(fontWeight = FontWeight.Normal), ) } if (followedBy) { Text( modifier = Modifier .padding(start = 4.dp) .background( color = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(2.dp), ) .padding(horizontal = 4.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, text = stringResource(LocalizedString.statusUiUserDetailFollowsYou), style = MaterialTheme.typography.bodySmall .copy(fontWeight = FontWeight.Normal), ) } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/utils/CardInfoSection.kt ================================================ package com.zhangke.fread.status.ui.utils import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.zhangke.fread.status.ui.BlogAuthorAvatar import com.zhangke.fread.status.ui.richtext.FreadRichText @Composable fun CardInfoSection( modifier: Modifier, avatar: String?, title: String, handle: String, description: String?, logo: Painter? = null, onClick: () -> Unit, onUrlClick: (String) -> Unit, actions: (@Composable RowScope.() -> Unit)? = null ) { Card( modifier = modifier, onClick = onClick, ) { Row( modifier = Modifier.fillMaxWidth(), ) { BlogAuthorAvatar( modifier = Modifier .padding(start = 16.dp, top = 16.dp, bottom = 16.dp) .size(40.dp), imageUrl = avatar, ) Column( modifier = Modifier .weight(1F) .padding(start = 8.dp, top = 16.dp, bottom = 16.dp), horizontalAlignment = Alignment.Start, ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Text( maxLines = 1, text = title, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleMedium, overflow = TextOverflow.Ellipsis, ) if (logo != null) { Image( modifier = Modifier.padding(start = 4.dp).size(16.dp), painter = logo, contentDescription = null, ) } } Text( maxLines = 1, text = handle, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, ) if (description.isNullOrEmpty().not()) { FreadRichText( modifier = Modifier.padding(top = 2.dp), content = description, mentions = emptyList(), emojis = emptyList(), tags = emptyList(), onHashtagClick = {}, onMentionClick = {}, onUrlClick = onUrlClick, maxLines = 3, ) } } Box(modifier = Modifier.align(Alignment.CenterVertically)) { if (actions != null) { Row( modifier = Modifier.padding(start = 8.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { actions() } } } } } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/utils/ScreenSize.kt ================================================ package com.zhangke.fread.status.ui.utils import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.unit.Dp @Composable @ReadOnlyComposable expect fun getScreenWidth(): Dp @Composable @ReadOnlyComposable expect fun getScreenHeight(): Dp ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/video/BlogVideos.kt ================================================ package com.zhangke.fread.status.ui.video import androidx.compose.runtime.Composable import com.zhangke.fread.status.blog.BlogMedia import com.zhangke.fread.status.ui.image.OnBlogMediaClick @Composable expect fun BlogVideos( mediaList: List, hideContent: Boolean, indexInList: Int, onMediaClick: OnBlogMediaClick, ) ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/video/VideoDurationFormatter.kt ================================================ package com.zhangke.fread.status.ui.video import kotlin.time.Duration import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.DurationUnit object VideoDurationFormatter { fun formatVideoProgressDesc(playerPosition: Long, durationMs: Long): String { val currentDurationDesc = formatVideoDuration(playerPosition) val durationDesc = formatVideoDuration(durationMs) return "$currentDurationDesc / $durationDesc" } private fun formatVideoDuration(durationMs: Long): String { val builder = StringBuilder() val duration = durationMs.milliseconds if (duration.isInfinite()) return "" val hours = duration.inWholeHours.hours if (hours > 0.hours) { builder.append(hours.toFormatString(DurationUnit.HOURS)) builder.append(":") } val minutes = (duration - hours).inWholeMinutes.minutes builder.append(minutes.toFormatString(DurationUnit.MINUTES)) builder.append(":") val seconds = (duration - hours - minutes).inWholeSeconds.seconds builder.append(seconds.toFormatString(DurationUnit.SECONDS)) return builder.toString() } private fun Duration.toFormatString(unit: DurationUnit): String { return toInt(unit).toString().padStart(2, '0') } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/com/zhangke/fread/status/ui/video/full/FullScreenVideoPlayer.kt ================================================ package com.zhangke.fread.status.ui.video.full import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.VolumeOff import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.ToolbarTokens import com.zhangke.framework.composable.noRippleClick import com.zhangke.framework.composable.video.VideoPlayer import com.zhangke.framework.composable.video.rememberVideoPlayerController import com.zhangke.framework.permission.RequireLocalStoragePermission import com.zhangke.framework.utils.PlatformUri import com.zhangke.fread.common.utils.LocalMediaFileHelper import com.zhangke.fread.status.ui.video.VideoDurationFormatter import kotlin.math.roundToInt @Composable fun FullScreenVideoPlayer( uri: PlatformUri, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { var panelVisible by remember { mutableStateOf(true) } Box( modifier = modifier .fillMaxSize() .noRippleClick { panelVisible = !panelVisible } .background(color = Color.Black) ) { val videoController = rememberVideoPlayerController( mediaUrl = uri.toString(), initialContentScale = ContentScale.Fit, ) VideoPlayer( modifier = Modifier, controller = videoController, ) AnimatedVisibility( modifier = Modifier.fillMaxSize(), visible = panelVisible, enter = fadeIn(), exit = fadeOut(), ) { Box(modifier = Modifier.fillMaxSize()) { FullScreenVideoPlayerPanel( modifier = Modifier .align(Alignment.BottomCenter) .navigationBarsPadding(), playing = videoController.isPlaying, mute = videoController.isMuted, onPlayClick = { videoController.play() }, onPauseClick = { videoController.pause() }, playerPosition = videoController.currentTimeInSeconds.roundToInt() * 1000L, duration = videoController.totalDurationInSeconds.roundToInt() * 1000L, onPositionChangeRequest = { videoController.seekTo(it / 1000F) }, onMuteClick = { videoController.mute() }, onUnmuteClick = { videoController.unmute() }, ) FullScreenPlayerToolBar( modifier = Modifier.align(Alignment.TopStart), videoUrl = uri.toString(), onBackClick = onBackClick, ) } } } } @Composable private fun FullScreenPlayerToolBar( modifier: Modifier, videoUrl: String, onBackClick: () -> Unit, ) { Box( modifier = modifier .fillMaxWidth() .statusBarsPadding() .padding(horizontal = ToolbarTokens.TopAppBarHorizontalPadding) .height(ToolbarTokens.ContainerHeight), ) { Toolbar.BackButton( onBackClick = onBackClick, tint = Color.White, ) var needSaveImage by remember { mutableStateOf(false) } if (needSaveImage) { val mediaFileHelper = LocalMediaFileHelper.current RequireLocalStoragePermission( onPermissionGranted = { mediaFileHelper.saveVideoToGallery(videoUrl) needSaveImage = false }, onPermissionDenied = { needSaveImage = false }, ) } Toolbar.DownloadButton( modifier = Modifier.align(Alignment.CenterEnd), onClick = { needSaveImage = true }, tint = MaterialTheme.colorScheme.inverseOnSurface, ) } } @Composable private fun FullScreenVideoPlayerPanel( modifier: Modifier = Modifier, playing: Boolean, playerPosition: Long, duration: Long, mute: Boolean, onPauseClick: () -> Unit, onPlayClick: () -> Unit, onPositionChangeRequest: (position: Long) -> Unit, onMuteClick: () -> Unit, onUnmuteClick: () -> Unit, ) { val progress = playerPosition / duration.toFloat() Column( modifier = modifier .fillMaxWidth() .padding(bottom = 16.dp), ) { PlayerProgress( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), progress = progress, onProgressChange = { progress -> onPositionChangeRequest((duration * progress).toLong()) }, ) Row( modifier = Modifier .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { PlayPauseIconButton( modifier = Modifier.padding(start = 4.dp), playing = playing, onPauseClick = onPauseClick, onPlayClick = onPlayClick, ) Spacer(modifier = Modifier.weight(1F)) Text( modifier = Modifier.padding(end = 4.dp), fontSize = 12.sp, text = VideoDurationFormatter.formatVideoProgressDesc(playerPosition, duration), color = Color.White, ) val icon = if (mute) { Icons.AutoMirrored.Filled.VolumeOff } else { Icons.AutoMirrored.Filled.VolumeUp } SimpleIconButton( onClick = { if (mute) { onUnmuteClick() } else { onMuteClick() } }, tint = Color.White, imageVector = icon, contentDescription = if (mute) "unmute" else "mute", ) } } } @Composable private fun PlayPauseIconButton( modifier: Modifier, playing: Boolean, onPauseClick: () -> Unit, onPlayClick: () -> Unit, ) { IconButton( modifier = modifier, onClick = { if (playing) { onPauseClick() } else { onPlayClick() } }, ) { val icon = if (playing) { Icons.Default.Pause } else { Icons.Default.PlayArrow } Icon( painter = rememberVectorPainter(icon), contentDescription = if (playing) "pause" else "play", tint = Color.White, ) } } @Composable private fun PlayerProgress( modifier: Modifier, progress: Float, onProgressChange: (progress: Float) -> Unit, ) { var progressInChanging by remember { mutableFloatStateOf(progress) } var sliding by remember { mutableStateOf(false) } val displayProgress = if (sliding) { progressInChanging } else { if (progress.isNaN()) { 0F } else { progress } } Slider( modifier = modifier, value = displayProgress.coerceAtLeast(0F).coerceAtMost(1F), onValueChange = { progressInChanging = it sliding = true }, onValueChangeFinished = { onProgressChange(progressInChanging) sliding = false }, colors = SliderDefaults.colors( thumbColor = Color.White, activeTrackColor = Color.White, activeTickColor = Color.White, ) ) } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/org/burnoutcrew/reorderable/DetectReorder.kt ================================================ /* * Copyright 2022 André Claßen * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.burnoutcrew.reorderable import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.forEachGesture import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInput fun Modifier.detectReorder(state: ReorderableState<*>) = this.then( Modifier.pointerInput(Unit) { forEachGesture { awaitPointerEventScope { val down = awaitFirstDown(requireUnconsumed = false) var drag: PointerInputChange? var overSlop = Offset.Zero do { drag = awaitPointerSlopOrCancellation(down.id, down.type) { change, over -> change.consume() overSlop = over } } while (drag != null && !drag.isConsumed) if (drag != null) { state.interactions.trySend(StartDrag(down.id, overSlop)) } } } } ) fun Modifier.detectReorderAfterLongPress(state: ReorderableState<*>) = this.then( Modifier.pointerInput(Unit) { forEachGesture { val down = awaitPointerEventScope { awaitFirstDown(requireUnconsumed = false) } awaitLongPressOrCancellation(down)?.also { state.interactions.trySend(StartDrag(down.id)) } } } ) ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/org/burnoutcrew/reorderable/DragCancelledAnimation.kt ================================================ /* * Copyright 2022 André Claßen * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.burnoutcrew.reorderable import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset interface DragCancelledAnimation { suspend fun dragCancelled(position: ItemPosition, offset: Offset) val position: ItemPosition? val offset: Offset } class NoDragCancelledAnimation : DragCancelledAnimation { override suspend fun dragCancelled(position: ItemPosition, offset: Offset) {} override val position: ItemPosition? = null override val offset: Offset = Offset.Zero } class SpringDragCancelledAnimation(private val stiffness: Float = Spring.StiffnessMediumLow) : DragCancelledAnimation { private val animatable = Animatable(Offset.Zero, Offset.VectorConverter) override val offset: Offset get() = animatable.value override var position by mutableStateOf(null) private set override suspend fun dragCancelled(position: ItemPosition, offset: Offset) { this.position = position animatable.snapTo(offset) animatable.animateTo( Offset.Zero, spring(stiffness = stiffness, visibilityThreshold = Offset.VisibilityThreshold) ) this.position = null } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/org/burnoutcrew/reorderable/DragGesture.kt ================================================ /* * Copyright 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.burnoutcrew.reorderable import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.isOutOfBounds import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastFirstOrNull import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withTimeout // Copied from DragGestureDetector , as long the pointer api isn`t ready. internal suspend fun AwaitPointerEventScope.awaitPointerSlopOrCancellation( pointerId: PointerId, pointerType: PointerType, onPointerSlopReached: (change: PointerInputChange, overSlop: Offset) -> Unit ): PointerInputChange? { if (currentEvent.isPointerUp(pointerId)) { return null // The pointer has already been lifted, so the gesture is canceled } var offset = Offset.Zero val touchSlop = viewConfiguration.pointerSlop(pointerType) var pointer = pointerId while (true) { val event = awaitPointerEvent() val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null if (dragEvent.isConsumed) { return null } else if (dragEvent.changedToUpIgnoreConsumed()) { val otherDown = event.changes.fastFirstOrNull { it.pressed } if (otherDown == null) { // This is the last "up" return null } else { pointer = otherDown.id } } else { offset += dragEvent.positionChange() val distance = offset.getDistance() var acceptedDrag = false if (distance >= touchSlop) { val touchSlopOffset = offset / distance * touchSlop onPointerSlopReached(dragEvent, offset - touchSlopOffset) if (dragEvent.isConsumed) { acceptedDrag = true } else { offset = Offset.Zero } } if (acceptedDrag) { return dragEvent } else { awaitPointerEvent(PointerEventPass.Final) if (dragEvent.isConsumed) { return null } } } } } internal suspend fun PointerInputScope.awaitLongPressOrCancellation( initialDown: PointerInputChange ): PointerInputChange? { var longPress: PointerInputChange? = null var currentDown = initialDown val longPressTimeout = viewConfiguration.longPressTimeoutMillis return try { // wait for first tap up or long press withTimeout(longPressTimeout) { awaitPointerEventScope { var finished = false while (!finished) { val event = awaitPointerEvent(PointerEventPass.Main) if (event.changes.fastAll { it.changedToUpIgnoreConsumed() }) { // All pointers are up finished = true } if ( event.changes.fastAny { it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding) } ) { finished = true // Canceled } // Check for cancel by position consumption. We can look on the Final pass of // the existing pointer event because it comes after the Main pass we checked // above. val consumeCheck = awaitPointerEvent(PointerEventPass.Final) if (consumeCheck.changes.fastAny { it.isConsumed }) { finished = true } if (!event.isPointerUp(currentDown.id)) { longPress = event.changes.fastFirstOrNull { it.id == currentDown.id } } else { val newPressed = event.changes.fastFirstOrNull { it.pressed } if (newPressed != null) { currentDown = newPressed longPress = currentDown } else { // should technically never happen as we checked it above finished = true } } } } } null } catch (_: TimeoutCancellationException) { longPress ?: initialDown } } private fun PointerEvent.isPointerUp(pointerId: PointerId): Boolean = changes.fastFirstOrNull { it.id == pointerId }?.pressed != true // This value was determined using experiments and common sense. // We can't use zero slop, because some hypothetical desktop/mobile devices can send // pointer events with a very high precision (but I haven't encountered any that send // events with less than 1px precision) private val mouseSlop = 0.125.dp private val defaultTouchSlop = 18.dp // The default touch slop on Android devices private val mouseToTouchSlopRatio = mouseSlop / defaultTouchSlop // TODO(demin): consider this as part of ViewConfiguration class after we make *PointerSlop* // functions public (see the comment at the top of the file). // After it will be a public API, we should get rid of `touchSlop / 144` and return absolute // value 0.125.dp.toPx(). It is not possible right now, because we can't access density. private fun ViewConfiguration.pointerSlop(pointerType: PointerType): Float { return when (pointerType) { PointerType.Mouse -> touchSlop * mouseToTouchSlopRatio else -> touchSlop } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/org/burnoutcrew/reorderable/ItemPosition.kt ================================================ package org.burnoutcrew.reorderable data class ItemPosition(val index: Int, val key: Any?) ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/org/burnoutcrew/reorderable/Reorderable.kt ================================================ /* * Copyright 2022 André Claßen * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.burnoutcrew.reorderable import androidx.compose.foundation.gestures.drag import androidx.compose.foundation.gestures.forEachGesture import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.util.fastFirstOrNull fun Modifier.reorderable( state: ReorderableState<*> ) = then( Modifier.pointerInput(Unit) { forEachGesture { val dragStart = state.interactions.receive() val down = awaitPointerEventScope { currentEvent.changes.fastFirstOrNull { it.id == dragStart.id } } if (down != null && state.onDragStart(down.position.x.toInt(), down.position.y.toInt())) { dragStart.offset?.apply { state.onDrag(x.toInt(), y.toInt()) } detectDrag( down.id, onDragEnd = { state.onDragCanceled() }, onDragCancel = { state.onDragCanceled() }, onDrag = { change, dragAmount -> change.consume() state.onDrag(dragAmount.x.toInt(), dragAmount.y.toInt()) }) } } }) internal suspend fun PointerInputScope.detectDrag( down: PointerId, onDragEnd: () -> Unit = { }, onDragCancel: () -> Unit = { }, onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, ) { awaitPointerEventScope { if ( drag(down) { onDrag(it, it.positionChange()) it.consume() } ) { // consume up if we quit drag gracefully with the up currentEvent.changes.forEach { if (it.changedToUp()) it.consume() } onDragEnd() } else { onDragCancel() } } } internal data class StartDrag(val id: PointerId, val offset: Offset? = null) ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableItem.kt ================================================ /* * Copyright 2022 André Claßen * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.burnoutcrew.reorderable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.grid.LazyGridItemScope import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.zIndex @Composable fun LazyItemScope.ReorderableItem( reorderableState: ReorderableState<*>, key: Any?, modifier: Modifier = Modifier, index: Int? = null, orientationLocked: Boolean = true, content: @Composable BoxScope.(isDragging: Boolean) -> Unit ) = ReorderableItem( reorderableState, key, modifier, Modifier.animateItem(), orientationLocked, index, content ) @Composable fun LazyGridItemScope.ReorderableItem( reorderableState: ReorderableState<*>, key: Any?, modifier: Modifier = Modifier, index: Int? = null, content: @Composable BoxScope.(isDragging: Boolean) -> Unit ) = ReorderableItem(reorderableState, key, modifier, Modifier.animateItem(), false, index, content) @Composable fun ReorderableItem( state: ReorderableState<*>, key: Any?, modifier: Modifier = Modifier, defaultDraggingModifier: Modifier = Modifier, orientationLocked: Boolean = true, index: Int? = null, content: @Composable BoxScope.(isDragging: Boolean) -> Unit ) { val isDragging = if (index != null) { index == state.draggingItemIndex } else { key == state.draggingItemKey } val draggingModifier = if (isDragging) { Modifier .zIndex(1f) .graphicsLayer { translationX = if (!orientationLocked || !state.isVerticalScroll) state.draggingItemLeft else 0f translationY = if (!orientationLocked || state.isVerticalScroll) state.draggingItemTop else 0f } } else { val cancel = if (index != null) { index == state.dragCancelledAnimation.position?.index } else { key == state.dragCancelledAnimation.position?.key } if (cancel) { Modifier.zIndex(1f) .graphicsLayer { translationX = if (!orientationLocked || !state.isVerticalScroll) state.dragCancelledAnimation.offset.x else 0f translationY = if (!orientationLocked || state.isVerticalScroll) state.dragCancelledAnimation.offset.y else 0f } } else { defaultDraggingModifier } } Box(modifier = modifier.then(draggingModifier)) { content(isDragging) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyGridState.kt ================================================ /* * Copyright 2022 André Claßen * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.burnoutcrew.reorderable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.lazy.grid.LazyGridItemInfo import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope @Composable fun rememberReorderableLazyGridState( onMove: (ItemPosition, ItemPosition) -> Unit, gridState: LazyGridState = rememberLazyGridState(), canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, maxScrollPerFrame: Dp = 20.dp, dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation() ): ReorderableLazyGridState { val maxScroll = with(LocalDensity.current) { maxScrollPerFrame.toPx() } val scope = rememberCoroutineScope() val state = remember(gridState) { ReorderableLazyGridState(gridState, scope, maxScroll, onMove, canDragOver, onDragEnd, dragCancelledAnimation) } LaunchedEffect(state) { state.visibleItemsChanged() .collect { state.onDrag(0, 0) } } LaunchedEffect(state) { while (true) { val diff = state.scrollChannel.receive() gridState.scrollBy(diff) } } return state } class ReorderableLazyGridState( val gridState: LazyGridState, scope: CoroutineScope, maxScrollPerFrame: Float, onMove: (fromIndex: ItemPosition, toIndex: ItemPosition) -> (Unit), canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation() ) : ReorderableState(scope, maxScrollPerFrame, onMove, canDragOver, onDragEnd, dragCancelledAnimation) { override val isVerticalScroll: Boolean get() = gridState.layoutInfo.orientation == Orientation.Vertical override val LazyGridItemInfo.left: Int get() = offset.x override val LazyGridItemInfo.right: Int get() = offset.x + size.width override val LazyGridItemInfo.top: Int get() = offset.y override val LazyGridItemInfo.bottom: Int get() = offset.y + size.height override val LazyGridItemInfo.width: Int get() = size.width override val LazyGridItemInfo.height: Int get() = size.height override val LazyGridItemInfo.itemIndex: Int get() = index override val LazyGridItemInfo.itemKey: Any get() = key override val visibleItemsInfo: List get() = gridState.layoutInfo.visibleItemsInfo override val viewportStartOffset: Int get() = gridState.layoutInfo.viewportStartOffset override val viewportEndOffset: Int get() = gridState.layoutInfo.viewportEndOffset override val firstVisibleItemIndex: Int get() = gridState.firstVisibleItemIndex override val firstVisibleItemScrollOffset: Int get() = gridState.firstVisibleItemScrollOffset override suspend fun scrollToItem(index: Int, offset: Int) { gridState.scrollToItem(index, offset) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableLazyListState.kt ================================================ /* * Copyright 2022 André Claßen * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.burnoutcrew.reorderable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope @Composable fun rememberReorderableLazyListState( onMove: (ItemPosition, ItemPosition) -> Unit, listState: LazyListState = rememberLazyListState(), canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, maxScrollPerFrame: Dp = 20.dp, dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation() ): ReorderableLazyListState { val maxScroll = with(LocalDensity.current) { maxScrollPerFrame.toPx() } val scope = rememberCoroutineScope() val state = remember(listState) { ReorderableLazyListState(listState, scope, maxScroll, onMove, canDragOver, onDragEnd, dragCancelledAnimation) } val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl LaunchedEffect(state) { state.visibleItemsChanged() .collect { state.onDrag(0, 0) } } LaunchedEffect(state) { var reverseDirection = !listState.layoutInfo.reverseLayout if (isRtl && listState.layoutInfo.orientation != Orientation.Vertical) { reverseDirection = !reverseDirection } val direction = if (reverseDirection) 1f else -1f while (true) { val diff = state.scrollChannel.receive() listState.scrollBy(diff * direction) } } return state } class ReorderableLazyListState( val listState: LazyListState, scope: CoroutineScope, maxScrollPerFrame: Float, onMove: (fromIndex: ItemPosition, toIndex: ItemPosition) -> (Unit), canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)? = null, onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))? = null, dragCancelledAnimation: DragCancelledAnimation = SpringDragCancelledAnimation() ) : ReorderableState( scope, maxScrollPerFrame, onMove, canDragOver, onDragEnd, dragCancelledAnimation ) { override val isVerticalScroll: Boolean get() = listState.layoutInfo.orientation == Orientation.Vertical override val LazyListItemInfo.left: Int get() = when { isVerticalScroll -> 0 listState.layoutInfo.reverseLayout -> listState.layoutInfo.viewportSize.width - offset - size else -> offset } override val LazyListItemInfo.top: Int get() = when { !isVerticalScroll -> 0 listState.layoutInfo.reverseLayout -> listState.layoutInfo.viewportSize.height - offset - size else -> offset } override val LazyListItemInfo.right: Int get() = when { isVerticalScroll -> 0 listState.layoutInfo.reverseLayout -> listState.layoutInfo.viewportSize.width - offset else -> offset + size } override val LazyListItemInfo.bottom: Int get() = when { !isVerticalScroll -> 0 listState.layoutInfo.reverseLayout -> listState.layoutInfo.viewportSize.height - offset else -> offset + size } override val LazyListItemInfo.width: Int get() = if (isVerticalScroll) 0 else size override val LazyListItemInfo.height: Int get() = if (isVerticalScroll) size else 0 override val LazyListItemInfo.itemIndex: Int get() = index override val LazyListItemInfo.itemKey: Any get() = key override val visibleItemsInfo: List get() = listState.layoutInfo.visibleItemsInfo override val viewportStartOffset: Int get() = listState.layoutInfo.viewportStartOffset override val viewportEndOffset: Int get() = listState.layoutInfo.viewportEndOffset override val firstVisibleItemIndex: Int get() = listState.firstVisibleItemIndex override val firstVisibleItemScrollOffset: Int get() = listState.firstVisibleItemScrollOffset override suspend fun scrollToItem(index: Int, offset: Int) { listState.scrollToItem(index, offset) } override fun onDragStart(offsetX: Int, offsetY: Int): Boolean = if (isVerticalScroll) { super.onDragStart(0, offsetY) } else { super.onDragStart(offsetX, 0) } override fun findTargets(x: Int, y: Int, selected: LazyListItemInfo) = if (isVerticalScroll) { super.findTargets(0, y, selected) } else { super.findTargets(x, 0, selected) } override fun chooseDropItem( draggedItemInfo: LazyListItemInfo?, items: List, curX: Int, curY: Int ) = if (isVerticalScroll) { super.chooseDropItem(draggedItemInfo, items, 0, curY) } else { super.chooseDropItem(draggedItemInfo, items, curX, 0) } } ================================================ FILE: commonbiz/status-ui/src/commonMain/kotlin/org/burnoutcrew/reorderable/ReorderableState.kt ================================================ /* * Copyright 2022 André Claßen * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.burnoutcrew.reorderable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.geometry.Offset import androidx.compose.ui.util.fastForEach import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlin.math.absoluteValue import kotlin.math.min import kotlin.math.sign abstract class ReorderableState( private val scope: CoroutineScope, private val maxScrollPerFrame: Float, private val onMove: (fromIndex: ItemPosition, toIndex: ItemPosition) -> (Unit), private val canDragOver: ((draggedOver: ItemPosition, dragging: ItemPosition) -> Boolean)?, private val onDragEnd: ((startIndex: Int, endIndex: Int) -> (Unit))?, val dragCancelledAnimation: DragCancelledAnimation ) { var draggingItemIndex by mutableStateOf(null) private set val draggingItemKey: Any? get() = selected?.itemKey protected abstract val T.left: Int protected abstract val T.top: Int protected abstract val T.right: Int protected abstract val T.bottom: Int protected abstract val T.width: Int protected abstract val T.height: Int protected abstract val T.itemIndex: Int protected abstract val T.itemKey: Any protected abstract val visibleItemsInfo: List protected abstract val firstVisibleItemIndex: Int protected abstract val firstVisibleItemScrollOffset: Int protected abstract val viewportStartOffset: Int protected abstract val viewportEndOffset: Int internal val interactions = Channel() internal val scrollChannel = Channel() val draggingItemLeft: Float get() = draggingLayoutInfo?.let { item -> (selected?.left ?: 0) + draggingDelta.x - item.left } ?: 0f val draggingItemTop: Float get() = draggingLayoutInfo?.let { item -> (selected?.top ?: 0) + draggingDelta.y - item.top } ?: 0f abstract val isVerticalScroll: Boolean private val draggingLayoutInfo: T? get() = visibleItemsInfo .firstOrNull { it.itemIndex == draggingItemIndex } private var draggingDelta by mutableStateOf(Offset.Zero) private var selected by mutableStateOf(null) private var autoscroller: Job? = null private val targets = mutableListOf() private val distances = mutableListOf() protected abstract suspend fun scrollToItem(index: Int, offset: Int) @OptIn(ExperimentalCoroutinesApi::class) internal fun visibleItemsChanged() = snapshotFlow { draggingItemIndex != null } .flatMapLatest { if (it) snapshotFlow { visibleItemsInfo } else flowOf(null) } .filterNotNull() .distinctUntilChanged { old, new -> old.firstOrNull()?.itemIndex == new.firstOrNull()?.itemIndex && old.count() == new.count() } internal open fun onDragStart(offsetX: Int, offsetY: Int): Boolean { val x: Int val y: Int if (isVerticalScroll) { x = offsetX y = offsetY + viewportStartOffset } else { x = offsetX + viewportStartOffset y = offsetY } return visibleItemsInfo .firstOrNull { x in it.left..it.right && y in it.top..it.bottom } ?.also { selected = it draggingItemIndex = it.itemIndex } != null } internal fun onDragCanceled() { val dragIdx = draggingItemIndex if (dragIdx != null) { val position = ItemPosition(dragIdx, selected?.itemKey) val offset = Offset(draggingItemLeft, draggingItemTop) scope.launch { dragCancelledAnimation.dragCancelled(position, offset) } } val startIndex = selected?.itemIndex val endIndex = draggingItemIndex selected = null draggingDelta = Offset.Zero draggingItemIndex = null cancelAutoScroll() onDragEnd?.apply { if (startIndex != null && endIndex != null) { invoke(startIndex, endIndex) } } } internal fun onDrag(offsetX: Int, offsetY: Int) { val selected = selected ?: return draggingDelta = Offset(draggingDelta.x + offsetX, draggingDelta.y + offsetY) val draggingItem = draggingLayoutInfo ?: return val startOffset = draggingItem.top + draggingItemTop val startOffsetX = draggingItem.left + draggingItemLeft chooseDropItem( draggingItem, findTargets(draggingDelta.x.toInt(), draggingDelta.y.toInt(), selected), startOffsetX.toInt(), startOffset.toInt() )?.also { targetItem -> if (targetItem.itemIndex == firstVisibleItemIndex || draggingItem.itemIndex == firstVisibleItemIndex) { scope.launch { onMove.invoke( ItemPosition(draggingItem.itemIndex, draggingItem.itemKey), ItemPosition(targetItem.itemIndex, targetItem.itemKey) ) scrollToItem(firstVisibleItemIndex, firstVisibleItemScrollOffset) } } else { onMove.invoke( ItemPosition(draggingItem.itemIndex, draggingItem.itemKey), ItemPosition(targetItem.itemIndex, targetItem.itemKey) ) } draggingItemIndex = targetItem.itemIndex } with(calcAutoScrollOffset(0, maxScrollPerFrame)) { if (this != 0f) autoscroll(this) } } private fun autoscroll(scrollOffset: Float) { if (scrollOffset != 0f) { if (autoscroller?.isActive == true) { return } autoscroller = scope.launch { var scroll = scrollOffset var start = 0L while (scroll != 0f && autoscroller?.isActive == true) { withFrameMillis { if (start == 0L) { start = it } else { scroll = calcAutoScrollOffset(it - start, maxScrollPerFrame) } } scrollChannel.trySend(scroll) } } } else { cancelAutoScroll() } } private fun cancelAutoScroll() { autoscroller?.cancel() autoscroller = null } protected open fun findTargets(x: Int, y: Int, selected: T): List { targets.clear() distances.clear() val left = x + selected.left val right = x + selected.right val top = y + selected.top val bottom = y + selected.bottom val centerX = (left + right) / 2 val centerY = (top + bottom) / 2 visibleItemsInfo.fastForEach { item -> if ( item.itemIndex == draggingItemIndex || item.bottom < top || item.top > bottom || item.right < left || item.left > right ) { return@fastForEach } if (canDragOver?.invoke( ItemPosition(item.itemIndex, item.itemKey), ItemPosition(selected.itemIndex, selected.itemKey) ) != false ) { val dx = (centerX - (item.left + item.right) / 2).absoluteValue val dy = (centerY - (item.top + item.bottom) / 2).absoluteValue val dist = dx * dx + dy * dy var pos = 0 for (j in targets.indices) { if (dist > distances[j]) { pos++ } else { break } } targets.add(pos, item) distances.add(pos, dist) } } return targets } protected open fun chooseDropItem(draggedItemInfo: T?, items: List, curX: Int, curY: Int): T? { if (draggedItemInfo == null) { return if (draggingItemIndex != null) items.lastOrNull() else null } var target: T? = null var highScore = -1 val right = curX + draggedItemInfo.width val bottom = curY + draggedItemInfo.height val dx = curX - draggedItemInfo.left val dy = curY - draggedItemInfo.top items.fastForEach { item -> if (dx > 0) { val diff = item.right - right if (diff < 0 && item.right > draggedItemInfo.right) { val score = diff.absoluteValue if (score > highScore) { highScore = score target = item } } } if (dx < 0) { val diff = item.left - curX if (diff > 0 && item.left < draggedItemInfo.left) { val score = diff.absoluteValue if (score > highScore) { highScore = score target = item } } } if (dy < 0) { val diff = item.top - curY if (diff > 0 && item.top < draggedItemInfo.top) { val score = diff.absoluteValue if (score > highScore) { highScore = score target = item } } } if (dy > 0) { val diff = item.bottom - bottom if (diff < 0 && item.bottom > draggedItemInfo.bottom) { val score = diff.absoluteValue if (score > highScore) { highScore = score target = item } } } } return target } private fun calcAutoScrollOffset(time: Long, maxScroll: Float): Float { val draggingItem = draggingLayoutInfo ?: return 0f val startOffset: Float val endOffset: Float val delta: Float if (isVerticalScroll) { startOffset = draggingItem.top + draggingItemTop endOffset = startOffset + draggingItem.height delta = draggingDelta.y } else { startOffset = draggingItem.left + draggingItemLeft endOffset = startOffset + draggingItem.width delta = draggingDelta.x } return when { delta > 0 -> (endOffset - viewportEndOffset).coerceAtLeast(0f) delta < 0 -> (startOffset - viewportStartOffset).coerceAtMost(0f) else -> 0f } .let { interpolateOutOfBoundsScroll((endOffset - startOffset).toInt(), it, time, maxScroll) } } companion object { private const val ACCELERATION_LIMIT_TIME_MS: Long = 1500 private val EaseOutQuadInterpolator: (Float) -> (Float) = { val t = 1 - it 1 - t * t * t * t } private val EaseInQuintInterpolator: (Float) -> (Float) = { it * it * it * it * it } private fun interpolateOutOfBoundsScroll( viewSize: Int, viewSizeOutOfBounds: Float, time: Long, maxScroll: Float, ): Float { if (viewSizeOutOfBounds == 0f) return 0f val outOfBoundsRatio = min(1f, 1f * viewSizeOutOfBounds.absoluteValue / viewSize) val cappedScroll = sign(viewSizeOutOfBounds) * maxScroll * EaseOutQuadInterpolator(outOfBoundsRatio) val timeRatio = if (time > ACCELERATION_LIMIT_TIME_MS) 1f else time.toFloat() / ACCELERATION_LIMIT_TIME_MS return (cappedScroll * EaseInQuintInterpolator(timeRatio)).let { if (it == 0f) { if (viewSizeOutOfBounds > 0) 1f else -1f } else { it } } } } } ================================================ FILE: commonbiz/status-ui/src/iosMain/kotlin/com/zhangke/fread/status/ui/utils/ScreenSize.ios.kt ================================================ package com.zhangke.fread.status.ui.utils import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.useContents import platform.UIKit.UIScreen @OptIn(ExperimentalForeignApi::class) @ReadOnlyComposable @Composable actual fun getScreenWidth(): Dp { return with(LocalDensity.current) { // TODO: This is not the correct way to get the screen width UIScreen.mainScreen().bounds().useContents { size.width.toInt().dp } } } @OptIn(ExperimentalForeignApi::class) @ReadOnlyComposable @Composable actual fun getScreenHeight(): Dp { return with(LocalDensity.current) { // TODO: This is not the correct way to get the screen width UIScreen.mainScreen().bounds().useContents { size.height.toInt().dp } } } ================================================ FILE: commonbiz/status-ui/src/iosMain/kotlin/com/zhangke/fread/status/ui/video/BlogVideos.ios.kt ================================================ package com.zhangke.fread.status.ui.video import androidx.compose.runtime.Composable import com.zhangke.fread.status.blog.BlogMedia import com.zhangke.fread.status.ui.image.OnBlogMediaClick @Composable actual fun BlogVideos( mediaList: List, hideContent: Boolean, indexInList: Int, onMediaClick: OnBlogMediaClick, ) { // TODO: Not implemented yet } ================================================ FILE: deleteuserdata.html ================================================

This document will tell you how to delete personal data in Fread.

1. Open the homepage of the app

2. Switch the bottom TAB to Profile

3. Click the more button of the account you logged in to

4. Click Log out

This way you can clear your login history.

If you want to clear all data, you can open the system settings page, select Fread and clear all data.

If you have any questions or suggestions about your personal data, do not hesitate to contact me at zhangkeport@gmail.com.

We do not save any of your personal information on the server, and your login information is all on the local disk of your device.

================================================ FILE: di-dependencis.md ================================================ # Kotlin-Inject Dependency Graphs Notes: - Sources: Kotlin files with `@Inject`, `@Provides`, or `@Component` annotations across all modules and source sets. - Arrow direction: dependency -> dependent (top to bottom). - `External:` nodes represent assisted inputs or values not bound in the same source set graph. ## androidMain Roots (no dependencies): - Activity - ApplicationContext - RssParser ```mermaid flowchart TB Necfc2dffe5["Activity"] Nb642dc2e53["ActivityPubDatabases"] Nd6c6a6bb97["ActivityPubLoggedAccountDatabase"] Ne582c54210["ActivityPubStatusDatabases"] N4b0697a4f6["ActivityPubStatusReadStateDatabases"] N8d5906e226["ApplicationContext"] N9be163fa87["BlueskyLoggedAccountDatabase"] Nebba756115["ContentConfigDatabases"] Ne1d90545f7["External: ActivityPubClientManager"] Na5181cbba3["External: ActivityPubLoggedAccountRepo"] N1a7b1cc6f8["External: AndroidApplicationComponent"] N4394529ff6["External: AndroidSystemBrowserLauncher"] N0cfc9f656b["External: Application"] N5e8baaa3bb["External: ApplicationCoroutineScope"] Nc3ea95cd13["External: ComponentActivity"] N7d16a1d3d9["External: DayNightHelper"] Ne47295ccce["External: FeedsRepoModuleStartup"] Nc1a3aa7b35["External: FreadConfigManager"] N2edcc08008["External: LanguageHelper"] Ne5a922a653["External: LanguageModuleStartup"] N956e808f53["External: LocalConfigManager"] N3f61db1662["External: StorageHelper"] Nade7b46a17["External: TextHandler"] N0c2f18938e["FlowSettings"] Nbeb46cbb74["FreadContentDatabase"] Nbee06c41c2["ImageLoader"] N316a667bb3["MixedStatusDatabases"] Nded7da349d["ModuleStartup"] Na3291e7bfa["NotificationsDatabase"] Ne5814d179d["OldFreadContentDatabase"] N010a4e8c92["PushInfoDatabase"] N9671344cf0["PushInfoRepo"] N88f2708bac["RssDatabases"] N33e9b1425c["RssParser"] N18af4a5dbc["SelectedAccountPublishingDatabase"] N6bb8cf23c8["SystemBrowserLauncher"] Na773e1b613["com.zhangke.fread.activitypub.app.internal.push.ActivityPubPushManager"] Ncd90060920["com.zhangke.fread.activitypub.app.internal.push.notification.PushNotificationManager"] N1c6b36a398["com.zhangke.fread.common.browser.AndroidSystemBrowserLauncher"] Nad842ac95b["com.zhangke.fread.common.browser.BrowserBridgeDialogActivityComponent"] N227f594a4d["com.zhangke.fread.common.browser.OAuthHandler"] N05a4c3e9fe["com.zhangke.fread.common.daynight.ActivityDayNightHelper"] N005b37105d["com.zhangke.fread.common.handler.ActivityTextHandler"] N039314d0f5["com.zhangke.fread.common.handler.TextHandler"] N13715080e6["com.zhangke.fread.common.language.ActivityLanguageHelper"] N53c4ecbc13["com.zhangke.fread.common.language.LanguageHelper"] Na893363c04["com.zhangke.fread.common.startup.LanguageModuleStartup"] N399ea0e365["com.zhangke.fread.common.utils.MediaFileHelper"] N7e131d7774["com.zhangke.fread.common.utils.PlatformUriHelper"] N82c109ec61["com.zhangke.fread.common.utils.StorageHelper"] N21e10f71ca["com.zhangke.fread.common.utils.ThumbnailHelper"] Nef22c2fc33["com.zhangke.fread.common.utils.ToastHelper"] N6d0399996e["com.zhangke.fread.di.AndroidActivityComponent"] N7a66505a85["com.zhangke.fread.di.AndroidApplicationComponent"] N1e131c0677["com.zhangke.fread.utils.ActivityHelper"] Necfc2dffe5 --> N1e131c0677 N3f61db1662 --> Nbee06c41c2 N0cfc9f656b --> N7a66505a85 N1a7b1cc6f8 --> N6d0399996e Nc3ea95cd13 --> N6d0399996e Nc1a3aa7b35 --> Na773e1b613 Ne1d90545f7 --> Na773e1b613 N9671344cf0 --> Na773e1b613 Na5181cbba3 --> Ncd90060920 N8d5906e226 --> Nb642dc2e53 N8d5906e226 --> Nd6c6a6bb97 N8d5906e226 --> Ne582c54210 N8d5906e226 --> N4b0697a4f6 N8d5906e226 --> N010a4e8c92 N010a4e8c92 --> N9671344cf0 N8d5906e226 --> Na3291e7bfa N8d5906e226 --> N9be163fa87 N4394529ff6 --> N6bb8cf23c8 N4394529ff6 --> N6bb8cf23c8 N8d5906e226 --> N039314d0f5 Nade7b46a17 --> N005b37105d Necfc2dffe5 --> N005b37105d N2edcc08008 --> Na893363c04 N8d5906e226 --> N0c2f18938e N8d5906e226 --> Nebba756115 N8d5906e226 --> Nbeb46cbb74 N8d5906e226 --> Ne5814d179d N8d5906e226 --> N316a667bb3 Ne47295ccce --> Nded7da349d Ne5a922a653 --> Nded7da349d Nc3ea95cd13 --> Nad842ac95b N8d5906e226 --> N227f594a4d Necfc2dffe5 --> N1c6b36a398 N7d16a1d3d9 --> N05a4c3e9fe Nc3ea95cd13 --> N05a4c3e9fe N956e808f53 --> N53c4ecbc13 N0cfc9f656b --> N53c4ecbc13 N2edcc08008 --> N13715080e6 Nc3ea95cd13 --> N13715080e6 N8d5906e226 --> N21e10f71ca N8d5906e226 --> N399ea0e365 N5e8baaa3bb --> N399ea0e365 N8d5906e226 --> N7e131d7774 N8d5906e226 --> N82c109ec61 Necfc2dffe5 --> Nef22c2fc33 N8d5906e226 --> N88f2708bac N8d5906e226 --> N18af4a5dbc ``` ## commonMain Roots (no dependencies): - ApplicationCoroutineScope - IFeedsScreenVisitor - IProfileScreenVisitor - com.zhangke.fread.activitypub.app.ActivityPubContentManager - com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubApplicationEntityAdapter - com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubBlogMetaAdapter - com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubContentAdapter - com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubCustomEmojiEntityAdapter - com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubPollAdapter - com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubTagAdapter - com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubTranslationEntityAdapter - com.zhangke.fread.activitypub.app.internal.adapter.PostStatusAttachmentAdapter - com.zhangke.fread.activitypub.app.internal.adapter.RegisterApplicationEntryAdapter - com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider - com.zhangke.fread.activitypub.app.internal.repo.platform.MastodonInstanceRepo - com.zhangke.fread.activitypub.app.internal.screen.status.post.adapter.CustomEmojiAdapter - com.zhangke.fread.activitypub.app.internal.uri.PlatformUriTransformer - com.zhangke.fread.activitypub.app.internal.uri.UserUriTransformer - com.zhangke.fread.activitypub.app.internal.usecase.emoji.MapCustomEmojiUseCase - com.zhangke.fread.bluesky.internal.adapter.BlueskyProfileAdapter - com.zhangke.fread.bluesky.internal.content.BlueskyContentManager - com.zhangke.fread.bluesky.internal.uri.platform.PlatformUriTransformer - com.zhangke.fread.bluesky.internal.uri.user.UserUriTransformer - com.zhangke.fread.bluesky.internal.usecase.GetAtIdentifierUseCase - com.zhangke.fread.bluesky.internal.usecase.UpdateProfileRecordUseCase - com.zhangke.fread.common.adapter.StatusUiStateAdapter - com.zhangke.fread.common.bubble.BubbleManager - com.zhangke.fread.common.deeplink.SelectedContentSwitcher - com.zhangke.fread.common.onboarding.OnboardingComponent - com.zhangke.fread.common.publish.PublishPostManager - com.zhangke.fread.common.startup.FeedsRepoModuleStartup - com.zhangke.fread.common.status.StatusIdGenerator - com.zhangke.fread.common.status.StatusUpdater - com.zhangke.fread.common.status.adapter.ContentConfigAdapter - com.zhangke.fread.common.status.usecase.FormatStatusDisplayTimeUseCase - com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewBlogUseCase - com.zhangke.fread.explore.screens.search.SearchViewModel - com.zhangke.fread.explore.usecase.BuildSearchResultUiStateUseCase - com.zhangke.fread.feeds.FeedsScreenVisitor - com.zhangke.fread.rss.RssAccountManager - com.zhangke.fread.rss.RssContentManager - com.zhangke.fread.rss.RssNotificationResolver - com.zhangke.fread.rss.RssPlatformResolver - com.zhangke.fread.rss.RssPublishManager - com.zhangke.fread.rss.internal.platform.RssPlatformTransformer - com.zhangke.fread.rss.internal.source.RssSourceTransformer - com.zhangke.fread.rss.internal.uri.RssUriTransformer - com.zhangke.fread.rss.internal.webfinger.RssSourceWebFingerTransformer ```mermaid flowchart TB N028b64f105["ApplicationCoroutineScope"] N2a8e209e43["BrowserInterceptor"] Nc7b67d9d2b["External: () -> AboutViewModel"] N8e5132e28c["External: () -> ActivityPubContentViewModel"] Nbd7f0dd8e9["External: () -> ActivityPubTimelineContainerViewModel"] N539851c83f["External: () -> BlueskyHomeContainerViewModel"] N93812a316c["External: () -> ContentHomeViewModel"] N7d66c5f8f4["External: () -> ExplorerContainerViewModel"] Ne12ccda2a8["External: () -> ExplorerHomeViewModel"] Nf04a3d7220["External: () -> HashtagTimelineContainerViewModel"] Ne9cd17762c["External: () -> HomeFeedsContainerViewModel"] Nca6fb5baac["External: () -> ImportFeedsViewModel"] Nbc470bf63e["External: () -> MainDrawerViewModel"] Na83d469aab["External: () -> MainViewModel"] N8ad85967a5["External: () -> MixedContentViewModel"] N47bc9f8990["External: () -> NotificationContainerViewModel"] Ncd1624647b["External: () -> NotificationsHomeViewModel"] Nc3119e5bf1["External: () -> ProfileHomeViewModel"] N3151de3431["External: () -> RssBlogDetailViewModel"] Nadad2fb90b["External: () -> SearchBarViewModel"] Nbf2f4aef51["External: () -> SearchSourceForAddViewModel"] Nbb771e20d1["External: () -> SearchViewModel"] Ne01b9b9f2a["External: () -> SelectContentTypeViewModel"] N45ba9694f3["External: () -> SelectPlatformViewModel"] Nbd1b619127["External: () -> ServerAboutViewModel"] Nafe62137ba["External: () -> ServerTrendsTagsViewModel"] N8377f0741c["External: () -> SettingScreenModel"] N8622fecfa8["External: () -> StatusContextViewModel"] N2cd9f1d3f1["External: () -> StatusListContainerViewModel"] N68eeaa5a05["External: () -> TrendingStatusViewModel"] N4fff844501["External: () -> UserAboutContainerViewModel"] N001ea94182["External: () -> UserDetailContainerViewModel"] N0642b33e0f["External: () -> UserTimelineContainerViewModel"] N31d4d14732["External: (BlogPlatform) -> AddActivityPubContentViewModel"] N57ed876353["External: (FormalBaseUrl) -> InstanceDetailViewModel"] N19fb61e6c8["External: (FormalBaseUrl, FormalUri) -> EditAccountInfoViewModel"] N53c634cf7b["External: (FormalBaseUrl?, Boolean, String?, String?, String?) -> AddBlueskyContentViewModel"] N21b95cbace["External: (List) -> MultiAccountPublishingViewModel"] N8c8c79030d["External: (PlatformLocator) -> AddListViewModel"] N501999f437["External: (PlatformLocator) -> CreatedListsViewModel"] N58fefad36d["External: (PlatformLocator) -> EditProfileViewModel"] N88e1e3a250["External: (PlatformLocator) -> ExplorerFeedsViewModel"] N07e54212e3["External: (PlatformLocator) -> FiltersListViewModel"] N9caacd2f72["External: (PlatformLocator) -> SearchAuthorViewModel"] Nd126f08d3c["External: (PlatformLocator) -> SearchHashtagViewModel"] Ne2a88c124a["External: (PlatformLocator) -> SearchStatusViewModel"] Nebb604cfe3["External: (PlatformLocator) -> TagListViewModel"] N67ba4edacc["External: (PlatformLocator, BlueskyFeeds) -> FeedsDetailViewModel"] Nda853e75a6["External: (PlatformLocator, Boolean) -> SearchUserViewModel"] N104d9467e2["External: (PlatformLocator, String) -> BskyUserDetailViewModel"] N75378e1d2e["External: (PlatformLocator, String) -> EditListViewModel"] Nc3405bb7fb["External: (PlatformLocator, String) -> SearchPlatformViewModel"] Nedd7048ddd["External: (PlatformLocator, String) -> SearchStatusViewModel"] Nb72392fcac["External: (PlatformLocator, String?) -> EditFilterViewModel"] N676803adc9["External: (PlatformLocator, String?, String?, String?) -> PublishPostViewModel"] N1e28617dd6["External: (PlatformLocator, UserListType, String?) -> UserListViewModel"] Nfa680b36d7["External: (PlatformLocator, UserListType, String?, FormalUri?, String?) -> UserListViewModel"] Nc6378cb626["External: (PostStatusScreenParams) -> PostStatusViewModel"] N8d922c0725["External: (StatusSource?) -> AddMixedFeedsViewModel"] N8ab1ebfa12["External: (String) -> EditContentConfigViewModel"] N27777dbf81["External: (String) -> EditMixedContentViewModel"] Ne704a6b3a2["External: (String) -> RssSourceViewModel"] N405cba47a3["External: (String) -> SelectAccountForPublishViewModel"] Nb3f3c43702["External: (String, PlatformLocator?, Boolean) -> UrlRedirectViewModel"] Ned074bd7ac["External: (String, String, PlatformLocator, StatusProviderProtocol) -> SelectAccountOpenStatusViewModel"] N6fc97c9b92["External: (String?, PlatformLocator?) -> BskyFollowingFeedsViewModel"] Nf925d9cef8["External: ActiveAccountsSynchronizer"] N8a212358d8["External: ActivityPubAccountEntityAdapter"] Nfa1084f8e1["External: ActivityPubAccountLogoutUseCase"] N0213a37765["External: ActivityPubAccountManager"] Ncd1f5b63de["External: ActivityPubApplicationEntityAdapter"] N763a9a135a["External: ActivityPubApplicationRepo"] Nc8e1a4e364["External: ActivityPubBlogMetaAdapter"] Ne1d90545f7["External: ActivityPubClientManager"] N99947e629d["External: ActivityPubContentAdapter"] N0f64956567["External: ActivityPubContentManager"] Nad6d79a615["External: ActivityPubContentMigrator"] N6344d7e729["External: ActivityPubCustomEmojiEntityAdapter"] N039bf3472c["External: ActivityPubDatabases"] Ne0d9d411b9["External: ActivityPubInstanceAdapter"] Ncdb4c0cbf8["External: ActivityPubLoggedAccountAdapter"] Nb76ef8157e["External: ActivityPubLoggedAccountDatabase"] Na5181cbba3["External: ActivityPubLoggedAccountRepo"] N69f9eb5086["External: ActivityPubNotificationResolver"] Na38935cd08["External: ActivityPubOAuthor"] Nddddaa04f9["External: ActivityPubPlatformEntityAdapter"] N31eb146107["External: ActivityPubPlatformRepo"] N3167de5c45["External: ActivityPubPlatformResolver"] N627bd14f2c["External: ActivityPubPollAdapter"] Na83ede2cf3["External: ActivityPubProvider"] Ne98830554d["External: ActivityPubPublishManager"] N42c7e0d519["External: ActivityPubPushManager"] N34b6c0ecf8["External: ActivityPubScreenProvider"] Ndd6fc86978["External: ActivityPubSearchAdapter"] N5f9390e388["External: ActivityPubSearchEngine"] Nf4928cbc67["External: ActivityPubSourceResolver"] N6b94985842["External: ActivityPubStartup"] N8a5de8d122["External: ActivityPubStatusAdapter"] N5a2a2e0c5a["External: ActivityPubStatusDatabases"] N99c776fc0d["External: ActivityPubStatusReadStateDatabases"] N63649e8010["External: ActivityPubStatusReadStateRepo"] N13f7294241["External: ActivityPubStatusResolver"] Nc46e325e61["External: ActivityPubTagAdapter"] N330df1c7c3["External: ActivityPubTimelineStatusRepo"] N31ad4ebc3e["External: ActivityPubTranslationEntityAdapter"] N682f9b093e["External: ActivityPubUrlInterceptor"] N764e0c2541["External: AppUpdateManager"] N5f6db2df23["External: BlogAuthorAdapter"] N50dddbe9d7["External: BlogPlatform"] Nff835f4d54["External: BlogPlatformResourceLoader"] Nfe8e0ad46e["External: BlueskyAccountAdapter"] Nbc589c4e22["External: BlueskyAccountManager"] N2a892e6731["External: BlueskyClientManager"] N6f03b95a32["External: BlueskyContentManager"] N9cf75ab12f["External: BlueskyContentMigrator"] N6c4085f9e6["External: BlueskyFeeds"] Nddf4738cc6["External: BlueskyFeedsAdapter"] N8a21a3c96d["External: BlueskyLoggedAccountDatabase"] N1d2b497a2d["External: BlueskyLoggedAccountManager"] Nf4707f5a66["External: BlueskyLoggedAccountRepo"] N4987b0cc45["External: BlueskyNotificationAdapter"] N441fc16448["External: BlueskyNotificationResolver"] N899a6a5d72["External: BlueskyPlatformRepo"] N02144e4562["External: BlueskyPlatformResolver"] Nc27915e91f["External: BlueskyProfileAdapter"] Nfbead4488e["External: BlueskyProvider"] Nd62fd06788["External: BlueskyPublishManager"] N4567427c74["External: BlueskyScreenProvider"] N5b4170cec6["External: BlueskySearchEngine"] N3eaf2103f9["External: BlueskyStatusAdapter"] N1309b38634["External: BlueskyStatusResolver"] Na61c85bfce["External: BlueskyStatusSourceResolver"] N47f3ffd6bb["External: Boolean"] N7c9f6610f5["External: BrowserLauncher"] Nadb2fdf057["External: BskyStartup"] N5e46761521["External: BskyStatusInteractiveUseCase"] N081b94b5b4["External: BskyUrlInterceptor"] Ne9b8efd0a2["External: BuildSearchResultUiStateUseCase"] Ne9d909c972["External: CommonStartup"] Ne857bdea8f["External: ContentConfigAdapter"] N97d9454d21["External: ContentConfigDatabases"] N90274c6fd3["External: CreateRecordUseCase"] N55ea83885d["External: CustomEmojiAdapter"] N7d16a1d3d9["External: DayNightHelper"] Nbfcb071d01["External: DeleteRecordUseCase"] Nd09094fac2["External: FormalBaseUrl"] Nd1f4ce6c75["External: FormalBaseUrl?"] Nc99a834813["External: FormalUri"] N92888aa436["External: FormalUri?"] Nc1a3aa7b35["External: FreadConfigManager"] Na7a1f228b2["External: FreadConfigModuleStartup"] Na2e59385e1["External: FreadContentDatabase"] Nce41f4af94["External: FreadContentDbMigrateManager"] N8b57d73a24["External: FreadContentRepo"] N09817aab3f["External: GenerateInitPostStatusUiStateUseCase"] N0d0f4fdfe4["External: GetAllListsUseCase"] Nec83350cf0["External: GetAtIdentifierUseCase"] Nf3ef21420d["External: GetCompletedNotificationUseCase"] N42538c992a["External: GetCustomEmojiUseCase"] Nab5171e538["External: GetDefaultBaseUrlUseCase"] Na6f7ba048e["External: GetFeedsStatusUseCase"] Nd6f3ec0423["External: GetFollowingFeedsUseCase"] N70c4ac54ad["External: GetInstanceAnnouncementUseCase"] Na603c25104["External: GetInstancePostStatusRulesUseCase"] Nb5aaf4d43a["External: GetServerTrendTagsUseCase"] N2bb5d05d8c["External: GetStatusContextUseCase"] Nb8ecde7600["External: GetTimelineStatusUseCase"] N0602ca7339["External: GetUserCreatedListUseCase"] N74f0cdf0b1["External: GetUserStatusUseCase"] N4e9026ec18["External: Lazy"] Nb11d90f398["External: Lazy"] N9c1fd3b6bd["External: List"] N956e808f53["External: LocalConfigManager"] N518c8572e4["External: LoggedAccountProvider"] N41dc86ed2f["External: LoginToBskyUseCase"] Nad83422d07["External: Map"] Nc7c563b2fe["External: Map"] N9d3d48dd27["External: MapCustomEmojiUseCase"] Ndc8255602a["External: MastodonHelper"] Nfad7dfebbc["External: MastodonInstanceRepo"] N6e341f5a7e["External: MixedStatusDatabases"] N62787d2759["External: MixedStatusRepo"] N10307617d7["External: NotificationsDatabase"] N197c4d966b["External: NotificationsRepo"] N2c5e13e7ef["External: OAuthHandler"] N9e91cb11f0["External: OldFreadContentDatabase"] N95905d8f88["External: OnboardingComponent"] Nfddd23677f["External: PinFeedsUseCase"] N0a28ac2206["External: PlatformLocator"] Nc9967a40ec["External: PlatformLocator?"] N32852bb49c["External: PlatformUriHelper"] Neac054df51["External: PlatformUriTransformer"] N1783552df5["External: PostStatusAttachmentAdapter"] N49e7f8879f["External: PostStatusScreenParams"] N80815d1c2a["External: PublishPostOnMultiAccountUseCase"] Nda7a54e5ba["External: PublishPostUseCase"] N469bf9f2a3["External: PublishingPostUseCase"] N2938d7ec78["External: RefactorToNewBlogUseCase"] N6d142895b2["External: RefactorToNewStatusUseCase"] N2890eb47aa["External: RefreshSessionUseCase"] N55690dfeac["External: RegisterApplicationEntryAdapter"] Nb4e8996b51["External: ReorderActivityPubTabUseCase"] Nd6a79489d8["External: RssAccountManager"] N6f090cefe2["External: RssContentManager"] N91552eee08["External: RssDatabases"] Nc70e828b1b["External: RssFetcher"] N0b062c0693["External: RssNotificationResolver"] N5654bbd309["External: RssParser"] N97256a9fdd["External: RssParserWrapper"] Nfa6e174e82["External: RssPlatformResolver"] N7cf470ad30["External: RssPlatformTransformer"] N590d5cdc23["External: RssPublishManager"] N842ce557d0["External: RssRepo"] N5dcd3839b5["External: RssScreenProvider"] Nb2629703b0["External: RssSearchEngine"] N72e9d0ab5f["External: RssSourceTransformer"] N267d44c00b["External: RssSourceWebFingerTransformer"] Na271be4ceb["External: RssStatusAdapter"] N8e0b95dcc8["External: RssStatusProvider"] Nc14a15894f["External: RssStatusRepo"] Nade6277f9a["External: RssStatusResolver"] N10bf2daf93["External: RssStatusSourceResolver"] N8d327df2c9["External: RssUriTransformer"] N02dc009f8d["External: SearchUserSourceNoTokenUseCase"] Nb25c31e711["External: SelectedAccountPublishingDatabase"] Nb029431978["External: SelectedAccountPublishingRepo"] Na9c5830ade["External: SelectedContentSwitcher"] N6be4ab7a3f["External: Set< IStatusProvider>"] Nec0f4747d1["External: Set"] N57f14f2301["External: Set"] N64da6866c5["External: StatusInteractiveUseCase"] N5cca16c063["External: StatusProviderProtocol"] Ncabe0092a5["External: StatusSource?"] N9c9d85dbd1["External: StatusUiStateAdapter"] N24985a671f["External: StatusUpdater"] N3f61db1662["External: StorageHelper"] N67372312b4["External: String"] N138e502bb4["External: String?"] Nc1297b1253["External: SystemBrowserLauncher"] Nade7b46a17["External: TextHandler"] Nb18c480f32["External: UnblockUserWithoutUriUseCase"] N6d0f1dfbc1["External: UnpinFeedsUseCase"] N45e019f62d["External: UpdateActivityPubUserListUseCase"] N7059866b06["External: UpdateBlockUseCase"] Nd05cf8618d["External: UpdateHomeTabUseCase"] N7d1aae4546["External: UpdatePinnedFeedsOrderUseCase"] N81a220d953["External: UpdatePreferencesUseCase"] N0ce0954aff["External: UpdateProfileRecordUseCase"] N46e9002ac7["External: UpdateRelationshipUseCase"] Nd9b7ca9e32["External: UploadBlobUseCase"] Nc8bc48368c["External: UploadMediaAttachmentUseCase"] N95ab25169e["External: UserListType"] Ndd50d64a83["External: UserRepo"] N6761ba2285["External: UserSourceTransformer"] N99c4d3861d["External: UserUriTransformer"] Na0e184b0f2["External: VotePollUseCase"] Nc01d179ed0["External: WebFingerBaseUrlToUserIdRepo"] N0e870bb90c["IFeedsScreenVisitor"] N14bea58fc8["IProfileScreenVisitor"] N03de987c55["IStatusProvider"] N4e74262a4b["ModuleScreenVisitor"] Nded7da349d["ModuleStartup"] N670240c0b4["Pair"] N600e1b9c05["Pair"] N938d7e8361["StatusProvider"] Na1a88a4190["ViewModelProvider.Factory"] N983c37f247["com.zhangke.fread.activitypub.app.ActivityPubAccountManager"] N5ffb317e4a["com.zhangke.fread.activitypub.app.ActivityPubContentManager"] N7599b19614["com.zhangke.fread.activitypub.app.ActivityPubNotificationResolver"] N89c600306b["com.zhangke.fread.activitypub.app.ActivityPubPlatformResolver"] N83a1cc9594["com.zhangke.fread.activitypub.app.ActivityPubProvider"] N2835833403["com.zhangke.fread.activitypub.app.ActivityPubPublishManager"] N4f3f62043e["com.zhangke.fread.activitypub.app.ActivityPubScreenProvider"] Nf146960e53["com.zhangke.fread.activitypub.app.ActivityPubSearchEngine"] N9d07219951["com.zhangke.fread.activitypub.app.ActivityPubSourceResolver"] Ndaf5bc8fd2["com.zhangke.fread.activitypub.app.ActivityPubStartup"] Nb208df30c6["com.zhangke.fread.activitypub.app.ActivityPubStatusResolver"] Ne75ab179a9["com.zhangke.fread.activitypub.app.ActivityPubUrlInterceptor"] N0a105fd185["com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubAccountEntityAdapter"] N35e77148e3["com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubApplicationEntityAdapter"] Na16b42f88a["com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubBlogMetaAdapter"] Na813625355["com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubContentAdapter"] N544ed29e84["com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubCustomEmojiEntityAdapter"] N34b4365f58["com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubInstanceAdapter"] N76cca85cc9["com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubLoggedAccountAdapter"] Neb04cc57d3["com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubPlatformEntityAdapter"] Nbb06f0148e["com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubPollAdapter"] N2dec340afe["com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubSearchAdapter"] N861b6e835c["com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter"] Na569151165["com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubTagAdapter"] N1679f20192["com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubTranslationEntityAdapter"] Nb44b380f31["com.zhangke.fread.activitypub.app.internal.adapter.PostStatusAttachmentAdapter"] Nefcac13e53["com.zhangke.fread.activitypub.app.internal.adapter.RegisterApplicationEntryAdapter"] Nfc93284358["com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager"] N2134b618db["com.zhangke.fread.activitypub.app.internal.auth.ActivityPubOAuthor"] Ncc9c430dcc["com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider"] Ncb9eccea48["com.zhangke.fread.activitypub.app.internal.migrate.ActivityPubContentMigrator"] N9d1106ba83["com.zhangke.fread.activitypub.app.internal.repo.WebFingerBaseUrlToUserIdRepo"] Nfe43780815["com.zhangke.fread.activitypub.app.internal.repo.account.ActivityPubLoggedAccountRepo"] N6ed815bee9["com.zhangke.fread.activitypub.app.internal.repo.application.ActivityPubApplicationRepo"] N5410010b86["com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo"] N2a59a5e095["com.zhangke.fread.activitypub.app.internal.repo.platform.BlogPlatformResourceLoader"] N9bdf8300e8["com.zhangke.fread.activitypub.app.internal.repo.platform.MastodonInstanceRepo"] Ne3f62b2909["com.zhangke.fread.activitypub.app.internal.repo.status.ActivityPubStatusReadStateRepo"] N3b9c39302d["com.zhangke.fread.activitypub.app.internal.repo.status.ActivityPubTimelineStatusRepo"] Nf7bf75d177["com.zhangke.fread.activitypub.app.internal.repo.user.UserRepo"] Na38fd2af9d["com.zhangke.fread.activitypub.app.internal.screen.account.EditAccountInfoViewModel"] Nfaa9c1fa63["com.zhangke.fread.activitypub.app.internal.screen.add.AddActivityPubContentViewModel"] N6198d8071e["com.zhangke.fread.activitypub.app.internal.screen.add.select.SelectPlatformViewModel"] N612cea2370["com.zhangke.fread.activitypub.app.internal.screen.content.ActivityPubContentViewModel"] Nd4ace750cf["com.zhangke.fread.activitypub.app.internal.screen.content.edit.EditContentConfigViewModel"] N7812ab16ff["com.zhangke.fread.activitypub.app.internal.screen.content.timeline.ActivityPubTimelineContainerViewModel"] N44d3ea7b14["com.zhangke.fread.activitypub.app.internal.screen.explorer.ExplorerContainerViewModel"] N30a6f4c6fa["com.zhangke.fread.activitypub.app.internal.screen.filters.edit.EditFilterViewModel"] N5135846fa4["com.zhangke.fread.activitypub.app.internal.screen.filters.list.FiltersListViewModel"] N180df54f96["com.zhangke.fread.activitypub.app.internal.screen.hashtag.HashtagTimelineContainerViewModel"] N93d36dda72["com.zhangke.fread.activitypub.app.internal.screen.instance.InstanceDetailViewModel"] N8be5538ade["com.zhangke.fread.activitypub.app.internal.screen.instance.about.ServerAboutViewModel"] N0b26d8c2a7["com.zhangke.fread.activitypub.app.internal.screen.instance.tags.ServerTrendsTagsViewModel"] N6194ba3b2c["com.zhangke.fread.activitypub.app.internal.screen.list.CreatedListsViewModel"] Nd497fddb90["com.zhangke.fread.activitypub.app.internal.screen.list.add.AddListViewModel"] N5fe718218e["com.zhangke.fread.activitypub.app.internal.screen.list.edit.EditListViewModel"] N650474fcdb["com.zhangke.fread.activitypub.app.internal.screen.search.SearchStatusViewModel"] N192d71f0fe["com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusViewModel"] N056d1822fe["com.zhangke.fread.activitypub.app.internal.screen.status.post.adapter.CustomEmojiAdapter"] Ne82168dd10["com.zhangke.fread.activitypub.app.internal.screen.status.post.usecase.GenerateInitPostStatusUiStateUseCase"] N4be37fe41d["com.zhangke.fread.activitypub.app.internal.screen.status.post.usecase.PublishPostUseCase"] N7635d8534f["com.zhangke.fread.activitypub.app.internal.screen.trending.TrendingStatusViewModel"] N715833dc58["com.zhangke.fread.activitypub.app.internal.screen.user.UserDetailContainerViewModel"] N989cdc6ac7["com.zhangke.fread.activitypub.app.internal.screen.user.about.UserAboutContainerViewModel"] Nf969754b6d["com.zhangke.fread.activitypub.app.internal.screen.user.list.UserListViewModel"] N223d04494e["com.zhangke.fread.activitypub.app.internal.screen.user.search.SearchUserViewModel"] N4a1af0534a["com.zhangke.fread.activitypub.app.internal.screen.user.status.StatusListContainerViewModel"] Nb04c20d695["com.zhangke.fread.activitypub.app.internal.screen.user.tags.TagListViewModel"] N4e57c358d5["com.zhangke.fread.activitypub.app.internal.screen.user.timeline.UserTimelineContainerViewModel"] Ncdcbb200e6["com.zhangke.fread.activitypub.app.internal.source.UserSourceTransformer"] N7a3a9d45dd["com.zhangke.fread.activitypub.app.internal.uri.PlatformUriTransformer"] N43f5eba7da["com.zhangke.fread.activitypub.app.internal.uri.UserUriTransformer"] Nb2e79fc2a4["com.zhangke.fread.activitypub.app.internal.usecase.ActivityPubAccountLogoutUseCase"] N836bf814dd["com.zhangke.fread.activitypub.app.internal.usecase.GetDefaultBaseUrlUseCase"] N0f2bcb0a97["com.zhangke.fread.activitypub.app.internal.usecase.GetInstanceAnnouncementUseCase"] Nafcad36339["com.zhangke.fread.activitypub.app.internal.usecase.GetServerTrendTagsUseCase"] Na8cd3aecea["com.zhangke.fread.activitypub.app.internal.usecase.UpdateActivityPubUserListUseCase"] Na6e95d2b4f["com.zhangke.fread.activitypub.app.internal.usecase.content.GetUserCreatedListUseCase"] N016044c115["com.zhangke.fread.activitypub.app.internal.usecase.content.ReorderActivityPubTabUseCase"] N5bbbb2a4e1["com.zhangke.fread.activitypub.app.internal.usecase.emoji.GetCustomEmojiUseCase"] N675ecab856["com.zhangke.fread.activitypub.app.internal.usecase.emoji.MapCustomEmojiUseCase"] N9691d9d391["com.zhangke.fread.activitypub.app.internal.usecase.media.UploadMediaAttachmentUseCase"] N5ad886c66c["com.zhangke.fread.activitypub.app.internal.usecase.platform.GetInstancePostStatusRulesUseCase"] N181f4c5848["com.zhangke.fread.activitypub.app.internal.usecase.source.user.SearchUserSourceNoTokenUseCase"] N358b0dfb7f["com.zhangke.fread.activitypub.app.internal.usecase.status.GetStatusContextUseCase"] N4d4cff5def["com.zhangke.fread.activitypub.app.internal.usecase.status.GetTimelineStatusUseCase"] N50cbb0fb75["com.zhangke.fread.activitypub.app.internal.usecase.status.GetUserStatusUseCase"] Ncb5bd399a8["com.zhangke.fread.activitypub.app.internal.usecase.status.StatusInteractiveUseCase"] Nbd8bdfc245["com.zhangke.fread.activitypub.app.internal.usecase.status.VotePollUseCase"] Nd25d4a2627["com.zhangke.fread.activitypub.app.internal.utils.MastodonHelper"] N92f4bfcb88["com.zhangke.fread.bluesky.BlueskyAccountManager"] N1dd24803fc["com.zhangke.fread.bluesky.BlueskyNotificationResolver"] N25db25751b["com.zhangke.fread.bluesky.BlueskyPlatformResolver"] N87085a1143["com.zhangke.fread.bluesky.BlueskyProvider"] Nb046baaf15["com.zhangke.fread.bluesky.BlueskyPublishManager"] Nb605bb1880["com.zhangke.fread.bluesky.BlueskyScreenProvider"] N5ddf6b757c["com.zhangke.fread.bluesky.BlueskySearchEngine"] Na441222c7c["com.zhangke.fread.bluesky.BlueskyStatusResolver"] N1dfc34f4e2["com.zhangke.fread.bluesky.BlueskyStatusSourceResolver"] N474c7183d7["com.zhangke.fread.bluesky.BskyStartup"] N08c08fff2f["com.zhangke.fread.bluesky.BskyUrlInterceptor"] Ncdcbec942e["com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccountManager"] N1f97cb5ed0["com.zhangke.fread.bluesky.internal.adapter.BlueskyAccountAdapter"] Naedc4712fe["com.zhangke.fread.bluesky.internal.adapter.BlueskyFeedsAdapter"] N41c71b6508["com.zhangke.fread.bluesky.internal.adapter.BlueskyNotificationAdapter"] N4a631eb113["com.zhangke.fread.bluesky.internal.adapter.BlueskyProfileAdapter"] N903dda79b9["com.zhangke.fread.bluesky.internal.adapter.BlueskyStatusAdapter"] Nb11e833c56["com.zhangke.fread.bluesky.internal.client.BlueskyClientManager"] N707513bc08["com.zhangke.fread.bluesky.internal.content.BlueskyContentManager"] Nd9d1761e06["com.zhangke.fread.bluesky.internal.migrate.BlueskyContentMigrator"] N1d38cc12fa["com.zhangke.fread.bluesky.internal.repo.BlueskyLoggedAccountRepo"] Nce8bbc5e3f["com.zhangke.fread.bluesky.internal.repo.BlueskyPlatformRepo"] Nd564c70d15["com.zhangke.fread.bluesky.internal.screen.add.AddBlueskyContentViewModel"] N172376f55d["com.zhangke.fread.bluesky.internal.screen.feeds.detail.FeedsDetailViewModel"] Nbf36bf8b4b["com.zhangke.fread.bluesky.internal.screen.feeds.explorer.ExplorerFeedsViewModel"] N9eda8650e6["com.zhangke.fread.bluesky.internal.screen.feeds.following.BskyFollowingFeedsViewModel"] N8a2fd8e592["com.zhangke.fread.bluesky.internal.screen.feeds.home.HomeFeedsContainerViewModel"] Nda5aa01e7b["com.zhangke.fread.bluesky.internal.screen.home.BlueskyHomeContainerViewModel"] N29e74314a8["com.zhangke.fread.bluesky.internal.screen.publish.PublishPostViewModel"] Nab40c1ba63["com.zhangke.fread.bluesky.internal.screen.search.SearchStatusViewModel"] Ncdacb19798["com.zhangke.fread.bluesky.internal.screen.user.detail.BskyUserDetailViewModel"] N1583773318["com.zhangke.fread.bluesky.internal.screen.user.edit.EditProfileViewModel"] Nc7c6b8dcad["com.zhangke.fread.bluesky.internal.screen.user.list.UserListViewModel"] N74000278ab["com.zhangke.fread.bluesky.internal.uri.platform.PlatformUriTransformer"] N81dee9c36e["com.zhangke.fread.bluesky.internal.uri.user.UserUriTransformer"] N029b3040d5["com.zhangke.fread.bluesky.internal.usecase.BskyStatusInteractiveUseCase"] N17da903f13["com.zhangke.fread.bluesky.internal.usecase.CreateRecordUseCase"] Ne683d65bf3["com.zhangke.fread.bluesky.internal.usecase.DeleteRecordUseCase"] N7363a346ce["com.zhangke.fread.bluesky.internal.usecase.GetAllListsUseCase"] Nba2b3d5f0a["com.zhangke.fread.bluesky.internal.usecase.GetAtIdentifierUseCase"] N889caf6c37["com.zhangke.fread.bluesky.internal.usecase.GetCompletedNotificationUseCase"] N228667e0fc["com.zhangke.fread.bluesky.internal.usecase.GetFeedsStatusUseCase"] Ndf1c7623c1["com.zhangke.fread.bluesky.internal.usecase.GetFollowingFeedsUseCase"] N5f37dbc6b4["com.zhangke.fread.bluesky.internal.usecase.GetStatusContextUseCase"] N665d0d39c0["com.zhangke.fread.bluesky.internal.usecase.LoginToBskyUseCase"] N685f01219b["com.zhangke.fread.bluesky.internal.usecase.PinFeedsUseCase"] N31f70f1ec8["com.zhangke.fread.bluesky.internal.usecase.PublishingPostUseCase"] N8fd33a8f34["com.zhangke.fread.bluesky.internal.usecase.RefreshSessionUseCase"] Nd89d3b24ef["com.zhangke.fread.bluesky.internal.usecase.UnblockUserWithoutUriUseCase"] N9cb819add0["com.zhangke.fread.bluesky.internal.usecase.UnpinFeedsUseCase"] Nff66e76bcd["com.zhangke.fread.bluesky.internal.usecase.UpdateBlockUseCase"] N594d88b966["com.zhangke.fread.bluesky.internal.usecase.UpdateHomeTabUseCase"] Naab06a2778["com.zhangke.fread.bluesky.internal.usecase.UpdatePinnedFeedsOrderUseCase"] N6b410ec35a["com.zhangke.fread.bluesky.internal.usecase.UpdatePreferencesUseCase"] Na1830f3db0["com.zhangke.fread.bluesky.internal.usecase.UpdateProfileRecordUseCase"] Nea78d24b39["com.zhangke.fread.bluesky.internal.usecase.UpdateRelationshipUseCase"] Nde323b227d["com.zhangke.fread.bluesky.internal.usecase.UploadBlobUseCase"] N8aaa81f108["com.zhangke.fread.common.CommonStartup"] N9df3abce6b["com.zhangke.fread.common.account.ActiveAccountsSynchronizer"] N395a854fc3["com.zhangke.fread.common.adapter.StatusUiStateAdapter"] N62d87a6a32["com.zhangke.fread.common.browser.BrowserLauncher"] Nc337a1e77c["com.zhangke.fread.common.browser.SelectAccount"] Nc6c2890ff7["com.zhangke.fread.common.bubble.BubbleManager"] N48d67cd949["com.zhangke.fread.common.config.FreadConfigManager"] N563775d5a8["com.zhangke.fread.common.config.LocalConfigManager"] N6f1eea881c["com.zhangke.fread.common.content.FreadContentDbMigrateManager"] Nbb3d22f17a["com.zhangke.fread.common.content.FreadContentRepo"] N380013ad7c["com.zhangke.fread.common.daynight.DayNightHelper"] Ndd799373e4["com.zhangke.fread.common.deeplink.SelectAccountForPublishViewModel"] N947480c46d["com.zhangke.fread.common.deeplink.SelectedContentSwitcher"] Nc69166447f["com.zhangke.fread.common.mixed.MixedStatusRepo"] N5dc007a633["com.zhangke.fread.common.onboarding.OnboardingComponent"] Nb807debe6e["com.zhangke.fread.common.publish.PublishPostManager"] N1adbb66287["com.zhangke.fread.common.review.FreadReviewManager"] Nb125055b98["com.zhangke.fread.common.startup.FeedsRepoModuleStartup"] N5d0b7117a1["com.zhangke.fread.common.startup.FreadConfigModuleStartup"] Nc40baa57c9["com.zhangke.fread.common.startup.StartupManager"] N718fa7cc7e["com.zhangke.fread.common.status.StatusIdGenerator"] N7da3998e6c["com.zhangke.fread.common.status.StatusUpdater"] Nfc9327537a["com.zhangke.fread.common.status.adapter.ContentConfigAdapter"] Nc40f3870c1["com.zhangke.fread.common.status.usecase.FormatStatusDisplayTimeUseCase"] N7666e419d6["com.zhangke.fread.common.update.AppUpdateManager"] Nf01dc9f97b["com.zhangke.fread.commonbiz.shared.blog.detail.RssBlogDetailViewModel"] Naba11c2cb4["com.zhangke.fread.commonbiz.shared.repo.SelectedAccountPublishingRepo"] N2147ec9332["com.zhangke.fread.commonbiz.shared.screen.publish.multi.MultiAccountPublishingViewModel"] N897a9829c9["com.zhangke.fread.commonbiz.shared.screen.status.account.SelectAccountOpenStatusViewModel"] N255f535a0d["com.zhangke.fread.commonbiz.shared.screen.status.context.StatusContextViewModel"] Nfd5998257b["com.zhangke.fread.commonbiz.shared.usecase.PublishPostOnMultiAccountUseCase"] N74f86e7350["com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewBlogUseCase"] Nc39e8692db["com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase"] Nce4eda4a4f["com.zhangke.fread.explore.screens.home.ExplorerHomeViewModel"] Ndcfb257eea["com.zhangke.fread.explore.screens.search.SearchViewModel"] Neb2dc71608["com.zhangke.fread.explore.screens.search.author.SearchAuthorViewModel"] N1b09b9b75d["com.zhangke.fread.explore.screens.search.bar.SearchBarViewModel"] N446af303e4["com.zhangke.fread.explore.screens.search.hashtag.SearchHashtagViewModel"] Ne52d757fc0["com.zhangke.fread.explore.screens.search.platform.SearchPlatformViewModel"] N5ae2eec09c["com.zhangke.fread.explore.screens.search.status.SearchStatusViewModel"] Ncd0dc85346["com.zhangke.fread.explore.usecase.BuildSearchResultUiStateUseCase"] N74364fb5a2["com.zhangke.fread.feature.message.repo.notification.NotificationsRepo"] N3740ccb747["com.zhangke.fread.feature.message.screens.home.NotificationsHomeViewModel"] N721ec2aeb7["com.zhangke.fread.feature.message.screens.notification.NotificationContainerViewModel"] Ndcf9a87edd["com.zhangke.fread.feeds.FeedsScreenVisitor"] N0d49daa8e1["com.zhangke.fread.feeds.pages.home.ContentHomeViewModel"] N98bfa34fa6["com.zhangke.fread.feeds.pages.home.feeds.MixedContentViewModel"] N5ee8323aae["com.zhangke.fread.feeds.pages.manager.add.mixed.AddMixedFeedsViewModel"] N17d22a06ae["com.zhangke.fread.feeds.pages.manager.add.type.SelectContentTypeViewModel"] Nf338fca47d["com.zhangke.fread.feeds.pages.manager.edit.EditMixedContentViewModel"] N4316f19e6b["com.zhangke.fread.feeds.pages.manager.importing.ImportFeedsViewModel"] N2c544d5e49["com.zhangke.fread.feeds.pages.manager.search.SearchSourceForAddViewModel"] N1d4374eee9["com.zhangke.fread.profile.screen.home.ProfileHomeViewModel"] Ncffa15e1ec["com.zhangke.fread.profile.screen.setting.SettingScreenModel"] N0f6ad4ad06["com.zhangke.fread.profile.screen.setting.about.AboutViewModel"] Nd37614cb3f["com.zhangke.fread.rss.RssAccountManager"] N031a96d2a3["com.zhangke.fread.rss.RssContentManager"] Ne85ec7d1d7["com.zhangke.fread.rss.RssNotificationResolver"] N7a6b1c837a["com.zhangke.fread.rss.RssPlatformResolver"] N597ca23223["com.zhangke.fread.rss.RssPublishManager"] N32d14f14d2["com.zhangke.fread.rss.RssScreenProvider"] N57fae43ca5["com.zhangke.fread.rss.RssSearchEngine"] N01d70e70ba["com.zhangke.fread.rss.RssStatusProvider"] N579f6837ac["com.zhangke.fread.rss.RssStatusResolver"] Nd239f94637["com.zhangke.fread.rss.RssStatusSourceResolver"] N48a15302a0["com.zhangke.fread.rss.internal.adapter.BlogAuthorAdapter"] Ne814135223["com.zhangke.fread.rss.internal.adapter.RssStatusAdapter"] Nb4ed30afb8["com.zhangke.fread.rss.internal.platform.RssPlatformTransformer"] Nfb562b9ab9["com.zhangke.fread.rss.internal.repo.RssRepo"] Na4cb7492f5["com.zhangke.fread.rss.internal.repo.RssStatusRepo"] Nbe677a0500["com.zhangke.fread.rss.internal.rss.RssFetcher"] N764c4a5430["com.zhangke.fread.rss.internal.rss.RssParserWrapper"] Nbcc71b73a5["com.zhangke.fread.rss.internal.screen.source.RssSourceViewModel"] Nc9048b2b23["com.zhangke.fread.rss.internal.source.RssSourceTransformer"] N676c5f88ec["com.zhangke.fread.rss.internal.uri.RssUriTransformer"] Nd73a3cbe21["com.zhangke.fread.rss.internal.webfinger.RssSourceWebFingerTransformer"] Nfd2d45eb2d["com.zhangke.fread.screen.main.MainViewModel"] N3b721c25d3["com.zhangke.fread.screen.main.drawer.MainDrawerViewModel"] N8b57d73a24 --> N3b721c25d3 N938d7e8361 --> N3b721c25d3 N764e0c2541 --> Nfd2d45eb2d Ne1d90545f7 --> N7599b19614 N31eb146107 --> N7599b19614 N518c8572e4 --> N7599b19614 N8a212358d8 --> N7599b19614 N8a5de8d122 --> N7599b19614 Na38935cd08 --> N983c37f247 Ne1d90545f7 --> N983c37f247 N518c8572e4 --> N983c37f247 Na5181cbba3 --> N983c37f247 N99c4d3861d --> N983c37f247 Ncdb4c0cbf8 --> N983c37f247 N42c7e0d519 --> N983c37f247 N028b64f105 --> N983c37f247 N8a212358d8 --> N983c37f247 Nfa1084f8e1 --> N983c37f247 N31eb146107 --> N89c600306b Ne1d90545f7 --> N2835833403 Nda7a54e5ba --> N2835833403 N99c4d3861d --> N4f3f62043e N518c8572e4 --> N4f3f62043e N31eb146107 --> Ne75ab179a9 Ne1d90545f7 --> Ne75ab179a9 N518c8572e4 --> Ne75ab179a9 N8a212358d8 --> Ne75ab179a9 N8a5de8d122 --> Ne75ab179a9 N8b57d73a24 --> Ne75ab179a9 Ne1d90545f7 --> Nb208df30c6 N74f0cdf0b1 --> Nb208df30c6 N99c4d3861d --> Nb208df30c6 N64da6866c5 --> Nb208df30c6 N8a5de8d122 --> Nb208df30c6 N2bb5d05d8c --> Nb208df30c6 Na0e184b0f2 --> Nb208df30c6 Nc01d179ed0 --> Nb208df30c6 N518c8572e4 --> Nb208df30c6 N31ad4ebc3e --> Nb208df30c6 N8b57d73a24 --> Ndaf5bc8fd2 Na5181cbba3 --> Ndaf5bc8fd2 N99947e629d --> Ndaf5bc8fd2 Nad6d79a615 --> Ndaf5bc8fd2 Na5181cbba3 --> Ndaf5bc8fd2 N6be4ab7a3f --> N938d7e8361 Nbc470bf63e --> N670240c0b4 Na83d469aab --> N670240c0b4 Nc3119e5bf1 --> N670240c0b4 N8377f0741c --> N670240c0b4 Nc7b67d9d2b --> N670240c0b4 Ne12ccda2a8 --> N670240c0b4 Nadad2fb90b --> N670240c0b4 Nbb771e20d1 --> N670240c0b4 N93812a316c --> N670240c0b4 N8ad85967a5 --> N670240c0b4 Nca6fb5baac --> N670240c0b4 Nbf2f4aef51 --> N670240c0b4 Ne01b9b9f2a --> N670240c0b4 Nbd7f0dd8e9 --> N670240c0b4 N8e5132e28c --> N670240c0b4 Nf04a3d7220 --> N670240c0b4 N7d66c5f8f4 --> N670240c0b4 Nbd1b619127 --> N670240c0b4 Nafe62137ba --> N670240c0b4 N68eeaa5a05 --> N670240c0b4 N4fff844501 --> N670240c0b4 N2cd9f1d3f1 --> N670240c0b4 N0642b33e0f --> N670240c0b4 N001ea94182 --> N670240c0b4 N45ba9694f3 --> N670240c0b4 Ncd1624647b --> N670240c0b4 N47bc9f8990 --> N670240c0b4 N539851c83f --> N670240c0b4 Ne9cd17762c --> N670240c0b4 N8622fecfa8 --> N670240c0b4 N3151de3431 --> N670240c0b4 N039bf3472c --> Nfe43780815 Nb76ef8157e --> Nfe43780815 N039bf3472c --> N6ed815bee9 Ne1d90545f7 --> N6ed815bee9 N55690dfeac --> N6ed815bee9 Ncd1f5b63de --> N6ed815bee9 N99c776fc0d --> Ne3f62b2909 N5a2a2e0c5a --> N3b9c39302d Nb8ecde7600 --> N3b9c39302d Ne1d90545f7 --> Nf7bf75d177 Nc01d179ed0 --> Nf7bf75d177 N6761ba2285 --> Nf7bf75d177 N039bf3472c --> N5410010b86 Ne1d90545f7 --> N5410010b86 Nddddaa04f9 --> N5410010b86 Ne0d9d411b9 --> N5410010b86 Nff835f4d54 --> N5410010b86 Nfad7dfebbc --> N5410010b86 N518c8572e4 --> N5410010b86 Ndc8255602a --> N2a59a5e095 N039bf3472c --> N9d1106ba83 Ne1d90545f7 --> N9d1106ba83 Ne1d90545f7 --> N9691d9d391 Ne1d90545f7 --> N0f2bcb0a97 Ne1d90545f7 --> Nafcad36339 Nc46e325e61 --> Nafcad36339 N8b57d73a24 --> Na8cd3aecea Ne1d90545f7 --> Nbd8bdfc245 N627bd14f2c --> Nbd8bdfc245 Ne1d90545f7 --> N358b0dfb7f N8a5de8d122 --> N358b0dfb7f N518c8572e4 --> N358b0dfb7f Ne1d90545f7 --> N50cbb0fb75 Nc01d179ed0 --> N50cbb0fb75 N8a5de8d122 --> N50cbb0fb75 N31eb146107 --> N50cbb0fb75 N518c8572e4 --> N50cbb0fb75 Ne1d90545f7 --> N4d4cff5def N31eb146107 --> N4d4cff5def N8a5de8d122 --> N4d4cff5def Ne1d90545f7 --> Ncb5bd399a8 N8a5de8d122 --> Ncb5bd399a8 N938d7e8361 --> N1b09b9b75d N24985a671f --> N1b09b9b75d Ne9b8efd0a2 --> N1b09b9b75d N9c9d85dbd1 --> N1b09b9b75d N6d142895b2 --> N1b09b9b75d Ne1d90545f7 --> Na6e95d2b4f N8b57d73a24 --> N016044c115 N938d7e8361 --> N5ae2eec09c N24985a671f --> N5ae2eec09c N9c9d85dbd1 --> N5ae2eec09c N6d142895b2 --> N5ae2eec09c N0a28ac2206 --> N5ae2eec09c Ne1d90545f7 --> N5bbbb2a4e1 N6344d7e729 --> N5bbbb2a4e1 N55ea83885d --> N5bbbb2a4e1 Ne1d90545f7 --> N181f4c5848 Ndd50d64a83 --> N181f4c5848 N99c4d3861d --> N181f4c5848 N6761ba2285 --> N181f4c5848 Nab5171e538 --> N181f4c5848 N938d7e8361 --> Ne52d757fc0 N0a28ac2206 --> Ne52d757fc0 N67372312b4 --> Ne52d757fc0 N764e0c2541 --> N0f6ad4ad06 Nade7b46a17 --> Ncffa15e1ec Nc1a3aa7b35 --> Ncffa15e1ec N7d16a1d3d9 --> Ncffa15e1ec N764e0c2541 --> Ncffa15e1ec Ne1d90545f7 --> N5ad886c66c N42c7e0d519 --> Nb2e79fc2a4 Na5181cbba3 --> Nb2e79fc2a4 N518c8572e4 --> Nb2e79fc2a4 N518c8572e4 --> N836bf814dd N938d7e8361 --> Neb2dc71608 N0a28ac2206 --> Neb2dc71608 N938d7e8361 --> N446af303e4 N0a28ac2206 --> N446af303e4 N938d7e8361 --> N1d4374eee9 Nf925d9cef8 --> N1d4374eee9 N938d7e8361 --> Nce4eda4a4f Nf925d9cef8 --> Nce4eda4a4f N9caacd2f72 --> N600e1b9c05 Nd126f08d3c --> N600e1b9c05 Nc3405bb7fb --> N600e1b9c05 Ne2a88c124a --> N600e1b9c05 N8d922c0725 --> N600e1b9c05 N27777dbf81 --> N600e1b9c05 N19fb61e6c8 --> N600e1b9c05 N8ab1ebfa12 --> N600e1b9c05 Nb72392fcac --> N600e1b9c05 N07e54212e3 --> N600e1b9c05 N57ed876353 --> N600e1b9c05 Nc6378cb626 --> N600e1b9c05 Nfa680b36d7 --> N600e1b9c05 Nebb604cfe3 --> N600e1b9c05 N31d4d14732 --> N600e1b9c05 N501999f437 --> N600e1b9c05 Nda853e75a6 --> N600e1b9c05 Nedd7048ddd --> N600e1b9c05 N75378e1d2e --> N600e1b9c05 N8c8c79030d --> N600e1b9c05 N53c634cf7b --> N600e1b9c05 N6fc97c9b92 --> N600e1b9c05 N104d9467e2 --> N600e1b9c05 N58fefad36d --> N600e1b9c05 N1e28617dd6 --> N600e1b9c05 N676803adc9 --> N600e1b9c05 Nedd7048ddd --> N600e1b9c05 N88e1e3a250 --> N600e1b9c05 N67ba4edacc --> N600e1b9c05 Ne704a6b3a2 --> N600e1b9c05 Nb3f3c43702 --> N600e1b9c05 N405cba47a3 --> N600e1b9c05 N21b95cbace --> N600e1b9c05 Ned074bd7ac --> N600e1b9c05 Ne1d90545f7 --> Na38fd2af9d N32852bb49c --> Na38fd2af9d Nd09094fac2 --> Na38fd2af9d Nc99a834813 --> Na38fd2af9d N938d7e8361 --> N7635d8534f Ne1d90545f7 --> N7635d8534f N24985a671f --> N7635d8534f N8a5de8d122 --> N7635d8534f N9c9d85dbd1 --> N7635d8534f N31eb146107 --> N7635d8534f N6d142895b2 --> N7635d8534f N518c8572e4 --> N7635d8534f N31eb146107 --> N93d36dda72 N8a212358d8 --> N93d36dda72 Nd09094fac2 --> N93d36dda72 Nb5aaf4d43a --> N0b26d8c2a7 Na5181cbba3 --> N8be5538ade N70c4ac54ad --> N8be5538ade N938d7e8361 --> N4316f19e6b N8b57d73a24 --> N4316f19e6b N32852bb49c --> N4316f19e6b N0602ca7339 --> N6194ba3b2c N0a28ac2206 --> N6194ba3b2c Ne1d90545f7 --> Nd497fddb90 N0a28ac2206 --> Nd497fddb90 Ne1d90545f7 --> N5fe718218e N0a28ac2206 --> N5fe718218e N67372312b4 --> N5fe718218e N95905d8f88 --> N17d22a06ae N938d7e8361 --> N17d22a06ae N938d7e8361 --> N5ee8323aae N8b57d73a24 --> N5ee8323aae N95905d8f88 --> N5ee8323aae Ncabe0092a5 --> N5ee8323aae N99c4d3861d --> Ncdcbb200e6 N8a212358d8 --> Ncdcbb200e6 N9d3d48dd27 --> Ncdcbb200e6 N6344d7e729 --> Ncdcbb200e6 N8b57d73a24 --> Nf338fca47d N938d7e8361 --> Nf338fca47d N67372312b4 --> Nf338fca47d N518c8572e4 --> Nfc93284358 Na5181cbba3 --> N2134b618db N763a9a135a --> N2134b618db Ne1d90545f7 --> N2134b618db Ncdb4c0cbf8 --> N2134b618db Nddddaa04f9 --> N2134b618db N039bf3472c --> N2134b618db N028b64f105 --> N2134b618db N2c5e13e7ef --> N2134b618db N8b57d73a24 --> N2134b618db Ndd50d64a83 --> N9d07219951 N99c4d3861d --> N9d07219951 N0f64956567 --> N83a1cc9594 N34b6c0ecf8 --> N83a1cc9594 N3167de5c45 --> N83a1cc9594 N5f9390e388 --> N83a1cc9594 N13f7294241 --> N83a1cc9594 Nf4928cbc67 --> N83a1cc9594 N0213a37765 --> N83a1cc9594 N69f9eb5086 --> N83a1cc9594 Ne98830554d --> N83a1cc9594 N938d7e8361 --> N2c544d5e49 Na83ede2cf3 --> N03de987c55 Nfbead4488e --> N03de987c55 N8e0b95dcc8 --> N03de987c55 N682f9b093e --> N2a8e209e43 N081b94b5b4 --> N2a8e209e43 N6b94985842 --> Nded7da349d Nadb2fdf057 --> Nded7da349d Ne9d909c972 --> Nded7da349d Na7a1f228b2 --> Nded7da349d N02dc009f8d --> Nf146960e53 Ne1d90545f7 --> Nf146960e53 N31eb146107 --> Nf146960e53 Ndd6fc86978 --> Nf146960e53 N8a5de8d122 --> Nf146960e53 Nc46e325e61 --> Nf146960e53 N8a212358d8 --> Nf146960e53 N518c8572e4 --> Nf146960e53 N8b57d73a24 --> N0d49daa8e1 N938d7e8361 --> N0d49daa8e1 Nf925d9cef8 --> N0d49daa8e1 Na9c5830ade --> N0d49daa8e1 N8b57d73a24 --> N98bfa34fa6 N62787d2759 --> N98bfa34fa6 N24985a671f --> N98bfa34fa6 N9c9d85dbd1 --> N98bfa34fa6 N938d7e8361 --> N98bfa34fa6 N6d142895b2 --> N98bfa34fa6 N31eb146107 --> N6198d8071e N95905d8f88 --> N6198d8071e N8b57d73a24 --> Nfaa9c1fa63 Na38935cd08 --> Nfaa9c1fa63 N99947e629d --> Nfaa9c1fa63 N95905d8f88 --> Nfaa9c1fa63 N50dddbe9d7 --> Nfaa9c1fa63 Ne1d90545f7 --> N650474fcdb N938d7e8361 --> N650474fcdb N31eb146107 --> N650474fcdb N518c8572e4 --> N650474fcdb N9c9d85dbd1 --> N650474fcdb N8a5de8d122 --> N650474fcdb N6d142895b2 --> N650474fcdb N24985a671f --> N650474fcdb N0a28ac2206 --> N650474fcdb N67372312b4 --> N650474fcdb N0213a37765 --> Ne82168dd10 Ne1d90545f7 --> N4be37fe41d Nc8bc48368c --> N4be37fe41d N1783552df5 --> N4be37fe41d N24985a671f --> N4be37fe41d N8a5de8d122 --> N4be37fe41d N42538c992a --> N192d71f0fe Na603c25104 --> N192d71f0fe N09817aab3f --> N192d71f0fe Ne1d90545f7 --> N192d71f0fe Nda7a54e5ba --> N192d71f0fe N49e7f8879f --> N192d71f0fe N32852bb49c --> N192d71f0fe N938d7e8361 --> N4e57c358d5 Nc01d179ed0 --> N4e57c358d5 N24985a671f --> N4e57c358d5 N9c9d85dbd1 --> N4e57c358d5 N31eb146107 --> N4e57c358d5 N8a5de8d122 --> N4e57c358d5 Ne1d90545f7 --> N4e57c358d5 N6d142895b2 --> N4e57c358d5 N518c8572e4 --> N4e57c358d5 N938d7e8361 --> N721ec2aeb7 N197c4d966b --> N721ec2aeb7 N9c9d85dbd1 --> N721ec2aeb7 N6d142895b2 --> N721ec2aeb7 N24985a671f --> N721ec2aeb7 N938d7e8361 --> N3740ccb747 Nf925d9cef8 --> N3740ccb747 Ne1d90545f7 --> Nf969754b6d N99c4d3861d --> Nf969754b6d Nc01d179ed0 --> Nf969754b6d N8a212358d8 --> Nf969754b6d N0a28ac2206 --> Nf969754b6d N95ab25169e --> Nf969754b6d N138e502bb4 --> Nf969754b6d N92888aa436 --> Nf969754b6d N138e502bb4 --> Nf969754b6d Ne1d90545f7 --> N223d04494e N0a28ac2206 --> N223d04494e N47f3ffd6bb --> N223d04494e Ne1d90545f7 --> Nb04c20d695 Nc46e325e61 --> Nb04c20d695 N0a28ac2206 --> Nb04c20d695 Ne1d90545f7 --> N4a1af0534a N8a5de8d122 --> N4a1af0534a N938d7e8361 --> N4a1af0534a N24985a671f --> N4a1af0534a N31eb146107 --> N4a1af0534a N9c9d85dbd1 --> N4a1af0534a N6d142895b2 --> N4a1af0534a N518c8572e4 --> N4a1af0534a N10307617d7 --> N74364fb5a2 Ne1d90545f7 --> N989cdc6ac7 Nc01d179ed0 --> N989cdc6ac7 N6344d7e729 --> N989cdc6ac7 N0213a37765 --> N715833dc58 N99c4d3861d --> N715833dc58 Ne1d90545f7 --> N715833dc58 N8a212358d8 --> N715833dc58 N6344d7e729 --> N715833dc58 Nfa1084f8e1 --> N715833dc58 N1d2b497a2d --> N92f4bfcb88 Nb18c480f32 --> N92f4bfcb88 N469bf9f2a3 --> Nb046baaf15 N899a6a5d72 --> N25db25751b N99c4d3861d --> Nb605bb1880 N6f03b95a32 --> N87085a1143 N4567427c74 --> N87085a1143 N02144e4562 --> N87085a1143 N5b4170cec6 --> N87085a1143 N1309b38634 --> N87085a1143 Na61c85bfce --> N87085a1143 Nbc589c4e22 --> N87085a1143 N441fc16448 --> N87085a1143 Nd62fd06788 --> N87085a1143 N2a892e6731 --> N5ddf6b757c Nfe8e0ad46e --> N5ddf6b757c Nec83350cf0 --> N5ddf6b757c N899a6a5d72 --> N5ddf6b757c N3eaf2103f9 --> N5ddf6b757c N899a6a5d72 --> N5ddf6b757c N1d2b497a2d --> N5ddf6b757c N2890eb47aa --> N474c7183d7 N9cf75ab12f --> N474c7183d7 Ne1d90545f7 --> N44d3ea7b14 N518c8572e4 --> N44d3ea7b14 N8a5de8d122 --> N44d3ea7b14 N8a212358d8 --> N44d3ea7b14 Nc46e325e61 --> N44d3ea7b14 N938d7e8361 --> N44d3ea7b14 N24985a671f --> N44d3ea7b14 N9c9d85dbd1 --> N44d3ea7b14 N6d142895b2 --> N44d3ea7b14 N8a212358d8 --> N861b6e835c Nc8e1a4e364 --> N861b6e835c N627bd14f2c --> N861b6e835c N6344d7e729 --> N861b6e835c N99c4d3861d --> N0a105fd185 N6344d7e729 --> N0a105fd185 N8a212358d8 --> N2dec340afe Nc46e325e61 --> N2dec340afe N8a5de8d122 --> N2dec340afe Neac054df51 --> N34b4365f58 Neac054df51 --> Neb04cc57d3 Ne0d9d411b9 --> N76cca85cc9 N99c4d3861d --> N76cca85cc9 N6344d7e729 --> N76cca85cc9 N8a21a3c96d --> N1d38cc12fa Neac054df51 --> Nce8bbc5e3f N3f61db1662 --> Nd25d4a2627 Nf4707f5a66 --> Nb11e833c56 Nfe8e0ad46e --> Nb11e833c56 N1d2b497a2d --> N665d0d39c0 N2a892e6731 --> N889caf6c37 N2a892e6731 --> N7363a346ce N2a892e6731 --> N8fd33a8f34 Nbc589c4e22 --> N8fd33a8f34 N81a220d953 --> Naab06a2778 N81a220d953 --> N9cb819add0 N2a892e6731 --> Nde323b227d N32852bb49c --> Nde323b227d N2a892e6731 --> N228667e0fc N3eaf2103f9 --> N228667e0fc N899a6a5d72 --> N228667e0fc Nd6f3ec0423 --> N594d88b966 N8b57d73a24 --> N594d88b966 N81a220d953 --> N685f01219b N2a892e6731 --> N5f37dbc6b4 N899a6a5d72 --> N5f37dbc6b4 N3eaf2103f9 --> N5f37dbc6b4 N2a892e6731 --> N17da903f13 N2a892e6731 --> Ne683d65bf3 N2a892e6731 --> N029b3040d5 N0ce0954aff --> N029b3040d5 N2a892e6731 --> Nea78d24b39 N90274c6fd3 --> Nea78d24b39 Nbfcb071d01 --> Nea78d24b39 N2a892e6731 --> Ndf1c7623c1 Nddf4738cc6 --> Ndf1c7623c1 N2a892e6731 --> N6b410ec35a N90274c6fd3 --> Nff66e76bcd Nbfcb071d01 --> Nff66e76bcd N2a892e6731 --> N31f70f1ec8 Nd9b7ca9e32 --> N31f70f1ec8 N2a892e6731 --> Nd89d3b24ef N7059866b06 --> Nd89d3b24ef N2a892e6731 --> Ncdcbec942e Nfe8e0ad46e --> Ncdcbec942e N899a6a5d72 --> Ncdcbec942e Nf4707f5a66 --> Ncdcbec942e N8b57d73a24 --> Ncb9eccea48 Na5181cbba3 --> Ncb9eccea48 Ne1d90545f7 --> N180df54f96 N938d7e8361 --> N180df54f96 N24985a671f --> N180df54f96 N8a5de8d122 --> N180df54f96 N31eb146107 --> N180df54f96 N9c9d85dbd1 --> N180df54f96 N6d142895b2 --> N180df54f96 N518c8572e4 --> N180df54f96 N41dc86ed2f --> Nd564c70d15 N8b57d73a24 --> Nd564c70d15 N899a6a5d72 --> Nd564c70d15 N95905d8f88 --> Nd564c70d15 Nd1f4ce6c75 --> Nd564c70d15 N47f3ffd6bb --> Nd564c70d15 N138e502bb4 --> Nd564c70d15 N138e502bb4 --> Nd564c70d15 N138e502bb4 --> Nd564c70d15 N2a892e6731 --> N29e74314a8 N0d0f4fdfe4 --> N29e74314a8 N32852bb49c --> N29e74314a8 Nc1a3aa7b35 --> N29e74314a8 N469bf9f2a3 --> N29e74314a8 N0a28ac2206 --> N29e74314a8 N138e502bb4 --> N29e74314a8 N138e502bb4 --> N29e74314a8 N138e502bb4 --> N29e74314a8 N938d7e8361 --> N7812ab16ff N24985a671f --> N7812ab16ff N8a5de8d122 --> N7812ab16ff N9c9d85dbd1 --> N7812ab16ff N6d142895b2 --> N7812ab16ff N518c8572e4 --> N7812ab16ff N330df1c7c3 --> N7812ab16ff N0213a37765 --> N7812ab16ff N63649e8010 --> N7812ab16ff Nc1a3aa7b35 --> N7812ab16ff Nd6f3ec0423 --> N9eda8650e6 N8b57d73a24 --> N9eda8650e6 N7d1aae4546 --> N9eda8650e6 N1d2b497a2d --> N9eda8650e6 N138e502bb4 --> N9eda8650e6 Nc9967a40ec --> N9eda8650e6 N8b57d73a24 --> Nd4ace750cf Nb4e8996b51 --> Nd4ace750cf N67372312b4 --> Nd4ace750cf N8b57d73a24 --> N612cea2370 N0213a37765 --> N612cea2370 N0602ca7339 --> N612cea2370 N45e019f62d --> N612cea2370 N2a892e6731 --> N172376f55d Nddf4738cc6 --> N172376f55d N90274c6fd3 --> N172376f55d Nbfcb071d01 --> N172376f55d Nfddd23677f --> N172376f55d N6d0f1dfbc1 --> N172376f55d N0a28ac2206 --> N172376f55d N6c4085f9e6 --> N172376f55d N2a892e6731 --> Nbf36bf8b4b Nddf4738cc6 --> Nbf36bf8b4b Nfddd23677f --> Nbf36bf8b4b N0a28ac2206 --> Nbf36bf8b4b N9c9d85dbd1 --> N8a2fd8e592 N24985a671f --> N8a2fd8e592 N938d7e8361 --> N8a2fd8e592 N6d142895b2 --> N8a2fd8e592 Na6f7ba048e --> N8a2fd8e592 N2a892e6731 --> Nab40c1ba63 N938d7e8361 --> Nab40c1ba63 N9c9d85dbd1 --> Nab40c1ba63 N24985a671f --> Nab40c1ba63 N6d142895b2 --> Nab40c1ba63 N899a6a5d72 --> Nab40c1ba63 N3eaf2103f9 --> Nab40c1ba63 N0a28ac2206 --> Nab40c1ba63 N67372312b4 --> Nab40c1ba63 N2a892e6731 --> Nc7c6b8dcad Nfe8e0ad46e --> Nc7c6b8dcad N46e9002ac7 --> Nc7c6b8dcad N7059866b06 --> Nc7c6b8dcad N0a28ac2206 --> Nc7c6b8dcad N95ab25169e --> Nc7c6b8dcad N138e502bb4 --> Nc7c6b8dcad N2a892e6731 --> Ncdacb19798 Nfe8e0ad46e --> Ncdacb19798 N46e9002ac7 --> Ncdacb19798 N7059866b06 --> Ncdacb19798 N1d2b497a2d --> Ncdacb19798 N2890eb47aa --> Ncdacb19798 N99c4d3861d --> Ncdacb19798 N0a28ac2206 --> Ncdacb19798 N67372312b4 --> Ncdacb19798 N1d2b497a2d --> N1583773318 N2a892e6731 --> N1583773318 Nd9b7ca9e32 --> N1583773318 N0a28ac2206 --> N1583773318 N8b57d73a24 --> Nda5aa01e7b N1d2b497a2d --> Nda5aa01e7b Nd05cf8618d --> Nda5aa01e7b N8b57d73a24 --> Nd9d1761e06 N1d2b497a2d --> Nd9d1761e06 Nfe8e0ad46e --> N903dda79b9 Nfe8e0ad46e --> N41c71b6508 N3eaf2103f9 --> N41c71b6508 Nc27915e91f --> Naedc4712fe N99c4d3861d --> N1f97cb5ed0 N2a892e6731 --> N1dfc34f4e2 N899a6a5d72 --> N1dfc34f4e2 Nfe8e0ad46e --> N1dfc34f4e2 N99c4d3861d --> N1dfc34f4e2 N2a892e6731 --> Na441222c7c N3eaf2103f9 --> Na441222c7c N899a6a5d72 --> Na441222c7c N99c4d3861d --> Na441222c7c N46e9002ac7 --> Na441222c7c N5e46761521 --> Na441222c7c N2bb5d05d8c --> Na441222c7c N99c4d3861d --> Na441222c7c N1d2b497a2d --> N08c08fff2f N2a892e6731 --> N08c08fff2f N3eaf2103f9 --> N08c08fff2f N899a6a5d72 --> N08c08fff2f N8b57d73a24 --> N08c08fff2f N2a892e6731 --> N1dd24803fc Nf3ef21420d --> N1dd24803fc N4987b0cc45 --> N1dd24803fc Nfe8e0ad46e --> N1dd24803fc Ne1d90545f7 --> N30a6f4c6fa N0a28ac2206 --> N30a6f4c6fa N138e502bb4 --> N30a6f4c6fa N8d327df2c9 --> N579f6837ac Nc14a15894f --> N579f6837ac N7cf470ad30 --> N57fae43ca5 N72e9d0ab5f --> N57fae43ca5 N842ce557d0 --> N57fae43ca5 N5f6db2df23 --> N57fae43ca5 N8d327df2c9 --> N57fae43ca5 N6f090cefe2 --> N01d70e70ba N5dcd3839b5 --> N01d70e70ba Nb2629703b0 --> N01d70e70ba Nd6a79489d8 --> N01d70e70ba Nade6277f9a --> N01d70e70ba N10bf2daf93 --> N01d70e70ba Nfa6e174e82 --> N01d70e70ba N0b062c0693 --> N01d70e70ba N590d5cdc23 --> N01d70e70ba Ne1d90545f7 --> N5135846fa4 N0a28ac2206 --> N5135846fa4 Na271be4ceb --> Na4cb7492f5 N842ce557d0 --> Na4cb7492f5 N91552eee08 --> Nfb562b9ab9 N5f6db2df23 --> Nfb562b9ab9 N8d327df2c9 --> Nfb562b9ab9 Nc70e828b1b --> Nfb562b9ab9 N842ce557d0 --> Nbcc71b73a5 N67372312b4 --> Nbcc71b73a5 N5f6db2df23 --> Ne814135223 N7cf470ad30 --> Ne814135223 N267d44c00b --> N48a15302a0 N5654bbd309 --> N764c4a5430 N97256a9fdd --> Nbe677a0500 N938d7e8361 --> Ndd799373e4 N67372312b4 --> Ndd799373e4 N028b64f105 --> N5d0b7117a1 Nb11d90f398 --> N5d0b7117a1 N57f14f2301 --> Nc40baa57c9 Nc1a3aa7b35 --> N9df3abce6b N8d327df2c9 --> N32d14f14d2 N8d327df2c9 --> Nd239f94637 N72e9d0ab5f --> Nd239f94637 N842ce557d0 --> Nd239f94637 Nc70e828b1b --> Nd239f94637 N956e808f53 --> N48d67cd949 N4e9026ec18 --> N563775d5a8 N8b57d73a24 --> N6f1eea881c N938d7e8361 --> N6f1eea881c N97d9454d21 --> N6f1eea881c Ne857bdea8f --> N6f1eea881c Na2e59385e1 --> Nbb3d22f17a N9e91cb11f0 --> Nbb3d22f17a Nc1a3aa7b35 --> N7666e419d6 Nce41f4af94 --> N8aaa81f108 Nf925d9cef8 --> N8aaa81f108 N938d7e8361 --> Nc69166447f N6e341f5a7e --> Nc69166447f Nad83422d07 --> Na1a88a4190 Nc7c563b2fe --> Na1a88a4190 N956e808f53 --> N380013ad7c Nb25c31e711 --> Naba11c2cb4 N956e808f53 --> N1adbb66287 N028b64f105 --> N1adbb66287 N0e870bb90c --> N4e74262a4b N14bea58fc8 --> N4e74262a4b Nc1297b1253 --> N62d87a6a32 Nec0f4747d1 --> Nc337a1e77c N7c9f6610f5 --> Nc337a1e77c N938d7e8361 --> Nc337a1e77c Na9c5830ade --> Nc337a1e77c N67372312b4 --> Nc337a1e77c Nc9967a40ec --> Nc337a1e77c N47f3ffd6bb --> Nc337a1e77c N938d7e8361 --> Nf01dc9f97b N938d7e8361 --> Nfd5998257b N2938d7ec78 --> Nc39e8692db N62787d2759 --> N255f535a0d N938d7e8361 --> N255f535a0d N24985a671f --> N255f535a0d N9c9d85dbd1 --> N255f535a0d N6d142895b2 --> N255f535a0d N938d7e8361 --> N2147ec9332 N32852bb49c --> N2147ec9332 N80815d1c2a --> N2147ec9332 Nb029431978 --> N2147ec9332 N9c1fd3b6bd --> N2147ec9332 N938d7e8361 --> N897a9829c9 N67372312b4 --> N897a9829c9 N67372312b4 --> N897a9829c9 N0a28ac2206 --> N897a9829c9 N5cca16c063 --> N897a9829c9 ``` ## iosMain Roots (no dependencies): - ActivityPubDatabases - ActivityPubLoggedAccountDatabase - ActivityPubStatusDatabases - ActivityPubStatusReadStateDatabases - BlueskyLoggedAccountDatabase - ContentConfigDatabases - FreadContentDatabase - MixedStatusDatabases - NSUserDefaults - NotificationsDatabase - OldFreadContentDatabase - RssDatabases - RssParser - SelectedAccountPublishingDatabase - UIApplication - com.zhangke.fread.activitypub.app.internal.push.ActivityPubPushManager - com.zhangke.fread.common.handler.TextHandler - com.zhangke.fread.common.utils.MediaFileHelper - com.zhangke.fread.common.utils.PlatformUriHelper - com.zhangke.fread.common.utils.StorageHelper - com.zhangke.fread.common.utils.ThumbnailHelper - com.zhangke.fread.common.utils.ToastHelper - com.zhangke.fread.startup.KRouterStartup ```mermaid flowchart TB Nb642dc2e53["ActivityPubDatabases"] Nd6c6a6bb97["ActivityPubLoggedAccountDatabase"] Ne582c54210["ActivityPubStatusDatabases"] N4b0697a4f6["ActivityPubStatusReadStateDatabases"] N9be163fa87["BlueskyLoggedAccountDatabase"] Nebba756115["ContentConfigDatabases"] N7d16a1d3d9["External: DayNightHelper"] N6c9049d7ad["External: FreadViewController"] N6a87135bd7["External: IosApplicationComponent"] N9bc311411d["External: IosSystemBrowserLauncher"] N7cdf6808bb["External: KRouterStartup"] N2edcc08008["External: LanguageHelper"] N6a3d55c4c9["External: Lazy"] N956e808f53["External: LocalConfigManager"] N3f61db1662["External: StorageHelper"] Nade7b46a17["External: TextHandler"] Ncc8d86a7b8["External: UIApplicationDelegateProtocol"] N0c2f18938e["FlowSettings"] Nbeb46cbb74["FreadContentDatabase"] Nbee06c41c2["ImageLoader"] N316a667bb3["MixedStatusDatabases"] Nded7da349d["ModuleStartup"] N1abd7ba261["NSUserDefaults"] Na3291e7bfa["NotificationsDatabase"] Ne5814d179d["OldFreadContentDatabase"] N88f2708bac["RssDatabases"] N33e9b1425c["RssParser"] N18af4a5dbc["SelectedAccountPublishingDatabase"] N6bb8cf23c8["SystemBrowserLauncher"] Nabaabf7d26["UIApplication"] N1559c35b03["UIViewController"] Na773e1b613["com.zhangke.fread.activitypub.app.internal.push.ActivityPubPushManager"] N12ab4280a1["com.zhangke.fread.common.browser.IosSystemBrowserLauncher"] N227f594a4d["com.zhangke.fread.common.browser.OAuthHandler"] N05a4c3e9fe["com.zhangke.fread.common.daynight.ActivityDayNightHelper"] N005b37105d["com.zhangke.fread.common.handler.ActivityTextHandler"] N039314d0f5["com.zhangke.fread.common.handler.TextHandler"] N13715080e6["com.zhangke.fread.common.language.ActivityLanguageHelper"] N53c4ecbc13["com.zhangke.fread.common.language.LanguageHelper"] N399ea0e365["com.zhangke.fread.common.utils.MediaFileHelper"] N7e131d7774["com.zhangke.fread.common.utils.PlatformUriHelper"] N82c109ec61["com.zhangke.fread.common.utils.StorageHelper"] N21e10f71ca["com.zhangke.fread.common.utils.ThumbnailHelper"] Nef22c2fc33["com.zhangke.fread.common.utils.ToastHelper"] Na3719264fb["com.zhangke.fread.di.IosActivityComponent"] N6ef831e5f5["com.zhangke.fread.di.IosApplicationComponent"] N3222557576["com.zhangke.fread.startup.KRouterStartup"] N1e131c0677["com.zhangke.fread.utils.ActivityHelper"] N6a3d55c4c9 --> N1e131c0677 N3f61db1662 --> Nbee06c41c2 N7cdf6808bb --> Nded7da349d Ncc8d86a7b8 --> N6ef831e5f5 N6c9049d7ad --> N1559c35b03 N6a87135bd7 --> Na3719264fb N9bc311411d --> N6bb8cf23c8 N6a3d55c4c9 --> N12ab4280a1 Nabaabf7d26 --> N12ab4280a1 Nabaabf7d26 --> N227f594a4d N7d16a1d3d9 --> N05a4c3e9fe N1abd7ba261 --> N0c2f18938e N956e808f53 --> N53c4ecbc13 N2edcc08008 --> N13715080e6 Nade7b46a17 --> N005b37105d ``` ================================================ FILE: documents/UserSource.drawio ================================================ ================================================ FILE: fastlane/Fastfile ================================================ default_platform(:android) platform :android do desc "Build a release bundle (AAB)" lane :build do gradle( task: "bundle", build_type: "release" ) end end ================================================ FILE: fastlane/metadata/android/en-US/full_description.txt ================================================ Fread is a fediverse microblogging client that supports Mastodon, Bluesky, and RSS. With Fread, you can seamlessly access all three platforms within a single app. It delivers a consistent microblogging experience while preserving the unique features of each network. Most importantly, Fread allows you to create unified feeds that blend content across different protocols, breaking down barriers and strengthening decentralization. On top of that, Fread is designed with a focus on beautiful, comfortable UI/UX, offering a smooth and enjoyable experience. ================================================ FILE: fastlane/metadata/android/en-US/short_description.txt ================================================ A fediverse microblogging client supporting Mastodon, Bluesky, and RSS. ================================================ FILE: fastlane/metadata/android/zh-CN/full_description.txt ================================================ Fread 是一个联邦宇宙 Micro blogging 社交客户端,目前已经支持了 Mastodon、Bluesky、RSS 三种社交平台,这意味着你可以在同一个 App 中同时使用这三种社交平台。 Fread 不仅提供了 Micro blogging 社交的一致性,也保持了不同平台的特色功能。 更重要的是,Fread 支持创建一个同时包含了三种来自不同平台的 Feeds 流,这打破了协议之间的壁垒,进一步增强了去中心化的能力。 另外 Fread 也专注于提供漂亮舒适的 UI/UX。 ================================================ FILE: fastlane/metadata/android/zh-CN/short_description.txt ================================================ 同时支持 Mastodon、Bluesky、RSS 的联邦宇宙客户端 ================================================ FILE: feature/explore/.gitignore ================================================ /build ================================================ FILE: feature/explore/build.gradle.kts ================================================ plugins { id("fread.project.feature.kmp") id("com.google.devtools.ksp") } android { namespace = "com.zhangke.fread.explore" } kotlin { sourceSets { commonMain { dependencies { implementation(project(":framework")) implementation(project(":bizframework:status-provider")) implementation(project(":commonbiz:common")) implementation(project(":commonbiz:analytics")) implementation(project(":commonbiz:sharedscreen")) implementation(project(":commonbiz:status-ui")) implementation(compose.components.resources) implementation(libs.jetbrains.lifecycle.viewmodel) implementation(libs.krouter.runtime) } } commonTest { dependencies { implementation(kotlin("test")) } } androidMain { dependencies { implementation(libs.androidx.core.ktx) } } } } dependencies { kspAll(libs.krouter.collecting.compiler) } compose { resources { publicResClass = false packageOfResClass = "com.zhangke.fread.explore" generateResClass = always } } ================================================ FILE: feature/explore/consumer-rules.pro ================================================ ================================================ FILE: feature/explore/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/ExploreNavEntryProvider.kt ================================================ package com.zhangke.fread.explore import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.zhangke.framework.nav.NavEntryProvider import com.zhangke.fread.explore.screens.search.SearchScreen import com.zhangke.fread.explore.screens.search.SearchScreenNavKey import kotlinx.serialization.modules.PolymorphicModuleBuilder import kotlinx.serialization.modules.subclass class ExploreNavEntryProvider : NavEntryProvider { override fun EntryProviderScope.build() { entry { key -> SearchScreen( locator = key.locator, protocol = key.protocol, query = key.query, ) } } override fun PolymorphicModuleBuilder.polymorph() { subclass(SearchScreenNavKey::class) } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/ExplorerElements.kt ================================================ package com.zhangke.fread.explore object ExplorerElements { const val SEARCH = "explorerSearch" const val SWITCH_ACCOUNT = "explorerSwitchAccount" } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/di/ExploreModule.kt ================================================ package com.zhangke.fread.explore.di import com.zhangke.framework.nav.NavEntryProvider import com.zhangke.fread.explore.ExploreNavEntryProvider import com.zhangke.fread.explore.screens.home.ExplorerHomeViewModel import com.zhangke.fread.explore.screens.search.SearchViewModel import com.zhangke.fread.explore.screens.search.author.SearchAuthorViewModel import com.zhangke.fread.explore.screens.search.bar.SearchBarViewModel import com.zhangke.fread.explore.screens.search.hashtag.SearchHashtagViewModel import com.zhangke.fread.explore.screens.search.platform.SearchPlatformViewModel import com.zhangke.fread.explore.screens.search.status.SearchStatusViewModel import com.zhangke.fread.explore.usecase.BuildSearchResultUiStateUseCase import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.bind import org.koin.dsl.module val exploreModule = module { factoryOf(::ExploreNavEntryProvider) bind NavEntryProvider::class factoryOf(::BuildSearchResultUiStateUseCase) viewModelOf(::ExplorerHomeViewModel) viewModelOf(::SearchViewModel) viewModelOf(::SearchBarViewModel) viewModelOf(::SearchAuthorViewModel) viewModelOf(::SearchHashtagViewModel) viewModelOf(::SearchPlatformViewModel) viewModelOf(::SearchStatusViewModel) } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/model/ExplorerItem.kt ================================================ package com.zhangke.fread.explore.model import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.Hashtag sealed interface ExplorerItem { val id: String data class ExplorerStatus(val status: StatusUiState) : ExplorerItem { override val id: String get() = status.status.id } data class ExplorerHashtag(val hashtag: Hashtag) : ExplorerItem { override val id: String get() = hashtag.name } data class ExplorerUser(val user: BlogAuthor, val following: Boolean) : ExplorerItem { override val id: String get() = user.uri.toString() } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/home/ExploreTab.kt ================================================ package com.zhangke.fread.explore.screens.home import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.zhangke.framework.nav.BaseTab import com.zhangke.framework.nav.TabOptions import com.zhangke.fread.commonbiz.Res import com.zhangke.fread.commonbiz.ic_explorer import org.jetbrains.compose.resources.painterResource class ExploreTab() : BaseTab() { override val options: TabOptions @Composable get() { val icon = painterResource(Res.drawable.ic_explorer) return remember { TabOptions( title = "Explore", icon = icon, ) } } @Composable override fun Content() { super.Content() ExplorerScreen() } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/home/ExplorerHomeUiState.kt ================================================ package com.zhangke.fread.explore.screens.home import com.zhangke.framework.nav.Tab import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.platform.BlogPlatform data class ExplorerHomeUiState( val selectedAccount: LoggedAccount?, val accountWithTabList: List>, ) { val locator: PlatformLocator? get() { if (selectedAccount == null) return null return PlatformLocator( baseUrl = selectedAccount.platform.baseUrl, accountUri = selectedAccount.uri, ) } val platform: BlogPlatform? get() = selectedAccount?.platform val tab: Tab? get() { return accountWithTabList.firstOrNull { it.first.uri == selectedAccount?.uri }?.second } companion object { fun default(): ExplorerHomeUiState { return ExplorerHomeUiState( selectedAccount = null, accountWithTabList = emptyList(), ) } } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/home/ExplorerHomeViewModel.kt ================================================ package com.zhangke.fread.explore.screens.home import androidx.lifecycle.ViewModel import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.nav.Tab import com.zhangke.fread.common.account.ActiveAccountsSynchronizer import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.PlatformLocator import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.update class ExplorerHomeViewModel( private val statusProvider: StatusProvider, private val activeAccountsSynchronizer: ActiveAccountsSynchronizer, ) : ViewModel() { private val _uiState = MutableStateFlow(ExplorerHomeUiState.default()) val uiState = _uiState.asStateFlow() init { launchInViewModel { statusProvider.accountManager .getAllAccountFlow() .collect { accountsList -> val currentUiState = _uiState.value var selectedAccount = currentUiState.selectedAccount if (selectedAccount == null) { val latestSelectedAccount = activeAccountsSynchronizer.activeAccountUriFlow.value selectedAccount = accountsList.firstOrNull { it.uri.toString() == latestSelectedAccount } } if (selectedAccount == null) { selectedAccount = accountsList.firstOrNull() } val newUiState = currentUiState.copy( accountWithTabList = convertContentsToWithTab(accountsList), selectedAccount = selectedAccount, ) _uiState.value = newUiState } } launchInViewModel { activeAccountsSynchronizer.activeAccountUriFlow .mapNotNull { it?.takeIf { it.isNotEmpty() } } .collect { lastActiveAccountUri -> val accounts = uiState.value.accountWithTabList.map { it.first } val selectedAccount = accounts.firstOrNull { it.uri.toString() == lastActiveAccountUri } if (selectedAccount != null && selectedAccount.uri != uiState.value.selectedAccount?.uri) { _uiState.update { it.copy(selectedAccount = selectedAccount) } } } } } fun onAccountSelected(account: LoggedAccount) { if (account.uri == uiState.value.selectedAccount?.uri) return launchInViewModel { _uiState.update { it.copy(selectedAccount = account) } activeAccountsSynchronizer.onAccountSelected(account.uri.toString()) } } private fun convertContentsToWithTab(accounts: List): List> { return accounts.mapNotNull { account -> statusProvider.screenProvider.getExplorerTab( locator = PlatformLocator( baseUrl = account.platform.baseUrl, accountUri = account.uri, ), platform = account.platform, )?.let { account to it } } } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/home/ExplorerScreen.kt ================================================ package com.zhangke.fread.explore.screens.home import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key 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.unit.Dp import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.LocalContentPadding import com.zhangke.framework.composable.LocalSnackbarHostState import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.composable.updateTopPadding import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.common.composable.EmptyContent import com.zhangke.fread.commonbiz.shared.LocalModuleScreenVisitor import com.zhangke.fread.explore.screens.search.bar.ExplorerSearchBar import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.ui.common.LocalNestedTabConnection import com.zhangke.fread.status.ui.common.NestedTabConnection import org.koin.compose.viewmodel.koinViewModel @Composable fun ExplorerScreen() { val backstack = LocalNavBackStack.currentOrThrow val feedsScreenVisitor = LocalModuleScreenVisitor.current.feedsScreenVisitor val viewModel = koinViewModel() val uiState by viewModel.uiState.collectAsState() ExplorerHomeContent( uiState = uiState, onAccountSelected = viewModel::onAccountSelected, onAccContentClick = { backstack.add(feedsScreenVisitor.getAddContentScreen()) }, ) } @Composable private fun ExplorerHomeContent( uiState: ExplorerHomeUiState, onAccountSelected: (LoggedAccount) -> Unit, onAccContentClick: () -> Unit, ) { val snackbarHostState = rememberSnackbarHostState() var topBarHeight: Dp by remember { mutableStateOf(40.dp) } Box( modifier = Modifier.fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { var searchBarActive by remember { mutableStateOf(false) } CompositionLocalProvider( LocalContentPadding provides updateTopPadding(topBarHeight) ) { if (!searchBarActive) { Box(modifier = Modifier.fillMaxSize()) { key(uiState.tab) { val tab = uiState.tab if (tab != null) { val nestedTabConnection = remember { NestedTabConnection() } CompositionLocalProvider( LocalSnackbarHostState provides snackbarHostState, LocalNestedTabConnection provides nestedTabConnection, ) { tab.Content() } } else { EmptyContent( modifier = Modifier.fillMaxSize(), onClick = onAccContentClick, ) } } } } } ExplorerSearchBar( selectedAccount = uiState.selectedAccount, accountList = uiState.accountWithTabList.map { it.first }, onAccountSelected = onAccountSelected, onHeightChanged = { topBarHeight = it }, onActiveChanged = { searchBarActive = it }, ) SnackbarHost( hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter) .padding(bottom = LocalContentPadding.current.calculateBottomPadding() + 16.dp), ) } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/search/SearchScreen.kt ================================================ package com.zhangke.fread.explore.screens.search 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.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.LocalSnackbarHostState import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.HorizontalPagerWithTab import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.explore.screens.search.author.SearchedAuthorTab import com.zhangke.fread.explore.screens.search.hashtag.SearchedHashtagTab import com.zhangke.fread.explore.screens.search.platform.SearchedPlatformTab import com.zhangke.fread.explore.screens.search.status.SearchedStatusTab import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusProviderProtocol import com.zhangke.fread.status.model.isActivityPub import kotlinx.serialization.Serializable @Serializable data class SearchScreenNavKey( val locator: PlatformLocator, val protocol: StatusProviderProtocol, val query: String, ) : NavKey @Composable fun SearchScreen( locator: PlatformLocator, protocol: StatusProviderProtocol, query: String, ) { val backStack = LocalNavBackStack.currentOrThrow SearchScreenContent( locator = locator, protocol = protocol, query = query, onBackClick = backStack::removeLastOrNull, ) } @Composable private fun SearchScreenContent( locator: PlatformLocator, protocol: StatusProviderProtocol, query: String, onBackClick: () -> Unit, ) { val snackbarHostState = rememberSnackbarHostState() Scaffold( modifier = Modifier.fillMaxSize(), topBar = { Toolbar( onBackClick = onBackClick, title = query, ) }, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { paddingValues -> val tabs = remember(locator, protocol, query) { buildList { add(SearchedAuthorTab(locator, query)) add(SearchedStatusTab(locator, query)) if (protocol.isActivityPub) { add(SearchedPlatformTab(locator, query)) } add(SearchedHashtagTab(locator, query)) } } Column( modifier = Modifier .fillMaxWidth() .padding(paddingValues) ) { CompositionLocalProvider( LocalSnackbarHostState provides snackbarHostState ) { HorizontalPagerWithTab(tabList = tabs) } } } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/search/SearchUiState.kt ================================================ package com.zhangke.fread.explore.screens.search class SearchUiState { } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/search/SearchViewModel.kt ================================================ package com.zhangke.fread.explore.screens.search import androidx.lifecycle.ViewModel class SearchViewModel(): ViewModel() { } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/search/author/SearchAuthorViewModel.kt ================================================ package com.zhangke.fread.explore.screens.search.author import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.TextString import com.zhangke.framework.controller.CommonLoadableController import com.zhangke.framework.controller.CommonLoadableUiState import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.PlatformLocator import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow open class SearchAuthorViewModel( private val statusProvider: StatusProvider, val locator: PlatformLocator, ) : ViewModel() { private val _snackMessageFlow = MutableSharedFlow() val snackMessageFlow: SharedFlow get() = _snackMessageFlow private val loadableController = CommonLoadableController( viewModelScope, onPostSnackMessage = { launchInViewModel { _snackMessageFlow.emit(it) } }) val uiState: StateFlow> get() = loadableController.uiState private val _openScreenFlow = MutableSharedFlow() val openScreenFlow: SharedFlow get() = _openScreenFlow fun initQuery(query: String) { if (loadableController.uiState.value.dataList.isNotEmpty()) return onRefresh(query) } fun onRefresh(query: String) { loadableController.onRefresh { statusProvider.searchEngine.searchAuthor(locator, query, null) } } fun onLoadMore(query: String) { val offset = uiState.value.dataList.size if (offset == 0) return loadableController.onLoadMore { statusProvider.searchEngine.searchAuthor(locator, query, offset) } } fun onUserInfoClick(blogAuthor: BlogAuthor) { launchInViewModel { statusProvider.screenProvider .getUserDetailScreen(locator, blogAuthor.uri, blogAuthor.userId) ?.let { _openScreenFlow.emit(it) } } } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/search/author/SearchedAuthorTab.kt ================================================ package com.zhangke.fread.explore.screens.search.author import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.LocalSnackbarHostState import com.zhangke.framework.composable.applyNestedScrollConnection import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.controller.CommonLoadableUiState import com.zhangke.framework.loadable.lazycolumn.LoadableInlineVideoLazyColumn import com.zhangke.framework.loadable.lazycolumn.rememberLoadableInlineVideoLazyColumnState import com.zhangke.framework.nav.BaseTab import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.TabOptions import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.common.browser.launchWebTabInApp import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.ui.BlogAuthorUi import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf internal class SearchedAuthorTab( private val locator: PlatformLocator, private val query: String, ) : BaseTab() { override val options: TabOptions @Composable get() = TabOptions( title = stringResource(LocalizedString.explorerSearchTabTitleAuthor), ) @Composable override fun Content() { super.Content() val backStack = LocalNavBackStack.currentOrThrow val viewModel = koinViewModel { parametersOf(locator) } val uiState by viewModel.uiState.collectAsState() val snackbarHostState = LocalSnackbarHostState.current LaunchedEffect(query) { viewModel.initQuery(query) } SearchedAuthorContent( uiState = uiState, onRefresh = { viewModel.onRefresh(query) }, onLoadMore = { viewModel.onLoadMore(query) }, onUserInfoClick = viewModel::onUserInfoClick, nestedScrollConnection = null, ) ConsumeFlow(viewModel.openScreenFlow) { backStack.add(it) } ConsumeSnackbarFlow( hostState = snackbarHostState, messageTextFlow = viewModel.snackMessageFlow ) } @OptIn(ExperimentalMaterialApi::class) @Composable private fun SearchedAuthorContent( uiState: CommonLoadableUiState, onRefresh: () -> Unit, onLoadMore: () -> Unit, onUserInfoClick: (BlogAuthor) -> Unit, nestedScrollConnection: NestedScrollConnection?, ) { val browserLauncher = LocalActivityBrowserLauncher.current val coroutineScope = rememberCoroutineScope() val state = rememberLoadableInlineVideoLazyColumnState( onRefresh = onRefresh, onLoadMore = onLoadMore, ) LoadableInlineVideoLazyColumn( modifier = Modifier .fillMaxSize() .applyNestedScrollConnection(nestedScrollConnection), state = state, refreshing = uiState.refreshing, loadState = uiState.loadMoreState, ) { itemsIndexed(uiState.dataList) { _, item -> BlogAuthorUi( modifier = Modifier.fillMaxWidth(), author = item, onClick = onUserInfoClick, onUrlClick = { browserLauncher.launchWebTabInApp(coroutineScope, it, locator) }, ) } } } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/search/bar/ExplorerSearchBar.kt ================================================ package com.zhangke.fread.explore.screens.search.bar import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.zhangke.framework.blur.applyBlurEffect import com.zhangke.framework.blur.blurEffectContainerColor import com.zhangke.framework.composable.BackHandler import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.InsetAwareSearchBar import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.inline.InlineVideoLazyColumn import com.zhangke.framework.composable.noRippleClick import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.utils.pxToDp import com.zhangke.fread.commonbiz.shared.composable.SearchResultUi import com.zhangke.fread.explore.screens.search.SearchScreenNavKey import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.ui.BlogAuthorAvatar import com.zhangke.fread.status.ui.ComposedStatusInteraction import com.zhangke.fread.status.ui.common.SelectAccountDialog import kotlinx.coroutines.flow.Flow import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun ExplorerSearchBar( selectedAccount: LoggedAccount?, accountList: List, onAccountSelected: (LoggedAccount) -> Unit, onHeightChanged: (Dp) -> Unit, onActiveChanged: (Boolean) -> Unit, ) { val density = LocalDensity.current val backStack = LocalNavBackStack.currentOrThrow var active by rememberSaveable { mutableStateOf(false) } val viewModel = koinViewModel() LaunchedEffect(selectedAccount) { viewModel.selectedAccount = selectedAccount } val uiState by viewModel.uiState.collectAsState() LaunchedEffect(active) { onActiveChanged(active) if (!active) { viewModel.onSearchQueryChanged("") } } val containerColor = MaterialTheme.colorScheme.surface InsetAwareSearchBar( modifier = Modifier .fillMaxWidth() .applyBlurEffect(enabled = !active, containerColor = containerColor) .onSizeChanged { if (!active) { onHeightChanged(it.height.pxToDp(density)) } } .padding(horizontal = 16.dp), insetContainerColor = blurEffectContainerColor(!active, containerColor), colors = SearchBarDefaults.colors( containerColor = blurEffectContainerColor(!active, containerColor), ), inputField = { SearchBarDefaults.InputField( modifier = Modifier.onFocusChanged { if (it.hasFocus && !active) { active = true } }, query = uiState.query, onQueryChange = viewModel::onSearchQueryChanged, onSearch = { val locator = uiState.locator val protocol = uiState.account?.platform?.protocol if (locator != null && protocol != null) { backStack.add( SearchScreenNavKey( locator = locator, protocol = protocol, query = uiState.query, ) ) } }, expanded = active, onExpandedChange = { active = it }, placeholder = { if (selectedAccount != null && accountList.size > 1) { Text( modifier = Modifier, text = stringResource( LocalizedString.explorerSearchBarHintSpecializePlatform, selectedAccount.platform.baseUrl.host, ), overflow = TextOverflow.Ellipsis, ) } else { Text( modifier = Modifier, text = stringResource(LocalizedString.explorerSearchBarHint), ) } }, leadingIcon = { if (active) { Toolbar.BackButton(onBackClick = { active = false }) } else { SimpleIconButton( onClick = { active = true }, imageVector = Icons.Default.Search, contentDescription = "Back", ) } }, trailingIcon = { SearchBarTrailing( active = active, selectedAccount = selectedAccount, onClearClick = { viewModel.onSearchQueryChanged("") }, accountList = accountList, onAccountSelected = onAccountSelected, ) }, ) }, expanded = active, onExpandedChange = { active = it }, content = { if (active) { BackHandler(true) { active = false } SearchContent( uiState = uiState, snackbarMessageFlow = viewModel.errorMessageFlow, composedStatusInteraction = viewModel.composedStatusInteraction, ) } }, ) ConsumeFlow(viewModel.openScreenFlow) { backStack.add(it) } } @Composable private fun SearchContent( uiState: SearchBarUiState, snackbarMessageFlow: Flow, composedStatusInteraction: ComposedStatusInteraction, ) { Box(modifier = Modifier.fillMaxSize()) { val state = rememberLazyListState() InlineVideoLazyColumn( modifier = Modifier.fillMaxSize(), state = state, ) { itemsIndexed(uiState.resultList) { index, item -> SearchResultUi( modifier = Modifier.fillMaxWidth(), searchResult = item, indexInList = index, composedStatusInteraction = composedStatusInteraction, ) } } val snackbarHostState = rememberSnackbarHostState() SnackbarHost( hostState = snackbarHostState, modifier = Modifier.align(Alignment.Center), ) ConsumeSnackbarFlow(snackbarHostState, snackbarMessageFlow) } } @Composable private fun SearchBarTrailing( active: Boolean, selectedAccount: LoggedAccount?, onClearClick: () -> Unit, accountList: List, onAccountSelected: (LoggedAccount) -> Unit, ) { if (active) { SimpleIconButton( onClick = onClearClick, imageVector = Icons.Default.Clear, contentDescription = "Clear Query", ) } else { if (accountList.size > 1) { var showSelectAccountPopup by remember { mutableStateOf(false) } Row( modifier = Modifier .padding(end = 8.dp) .background( color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(23.dp), ) .noRippleClick { showSelectAccountPopup = true }, verticalAlignment = Alignment.CenterVertically, ) { Spacer(modifier = Modifier.width(8.dp)) Icon( imageVector = Icons.Default.ArrowDropDown, contentDescription = "Select Account", ) Spacer(modifier = Modifier.width(4.dp)) BlogAuthorAvatar( modifier = Modifier.size(40.dp), imageUrl = selectedAccount?.avatar, ) } if (showSelectAccountPopup) { SelectAccountDialog( accountList = accountList, selectedAccounts = selectedAccount?.let { listOf(it) } ?: emptyList(), onDismissRequest = { showSelectAccountPopup = false }, onAccountClicked = onAccountSelected, ) } } } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/search/bar/SearchBarUiState.kt ================================================ package com.zhangke.fread.explore.screens.search.bar import com.zhangke.fread.common.status.model.SearchResultUiState import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.PlatformLocator data class SearchBarUiState( val locator: PlatformLocator?, val account: LoggedAccount?, val query: String, val resultList: List, ) ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/search/bar/SearchBarViewModel.kt ================================================ package com.zhangke.fread.explore.screens.search.bar import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.common.status.model.SearchResultUiState import com.zhangke.fread.commonbiz.shared.feeds.IInteractiveHandler import com.zhangke.fread.commonbiz.shared.feeds.InteractiveHandleResult import com.zhangke.fread.commonbiz.shared.feeds.InteractiveHandler import com.zhangke.fread.commonbiz.shared.feeds.handle import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.explore.usecase.BuildSearchResultUiStateUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.richtext.preParse import com.zhangke.fread.status.search.SearchResult import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update class SearchBarViewModel( private val statusProvider: StatusProvider, statusUpdater: StatusUpdater, private val buildSearchResultUiState: BuildSearchResultUiStateUseCase, statusUiStateAdapter: StatusUiStateAdapter, refactorToNewStatus: RefactorToNewStatusUseCase, ) : ViewModel(), IInteractiveHandler by InteractiveHandler( statusProvider = statusProvider, statusUpdater = statusUpdater, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, ) { var selectedAccount: LoggedAccount? = null set(value) { if (field == value) return field = value _uiState.update { it.copy( locator = locator, query = "", account = value, resultList = emptyList(), ) } } private val locator: PlatformLocator? get() { val account = selectedAccount ?: return null return PlatformLocator( baseUrl = account.platform.baseUrl, accountUri = account.uri, ) } private var searchJob: Job? = null private val _uiState = MutableStateFlow( SearchBarUiState( locator = locator, query = "", account = null, resultList = emptyList(), ) ) val uiState = _uiState.asStateFlow() init { initInteractiveHandler( coroutineScope = viewModelScope, onInteractiveHandleResult = { it.handleResult() }, ) } fun onSearchQueryChanged(query: String) { if (query.isEmpty()) { searchJob?.cancel() _uiState.update { it.copy(query = "", resultList = emptyList()) } return } val locator = locator ?: return if (query == _uiState.value.query) return _uiState.update { it.copy(query = query) } searchJob?.cancel() searchJob = launchInViewModel { statusProvider.searchEngine .search(locator, query) .map { list -> list.filterIsInstance() .map { it.status } .preParse() list }.map { list -> list.map { buildSearchResultUiState(locator, it) } }.onSuccess { searchResult -> _uiState.update { it.copy(resultList = searchResult) } } } } private suspend fun InteractiveHandleResult.handleResult() { handle( uiStatusUpdater = { newUiState -> _uiState.update { currentUiState -> currentUiState.copy( resultList = currentUiState.resultList.map { if (it !is SearchResultUiState.SearchedStatus) return@map it if (it.status.status.intrinsicBlog.id != newUiState.status.intrinsicBlog.id) return@map it return@map it.copy(status = newUiState) } ) } }, deleteStatus = { statusId -> _uiState.update { state -> state.copy( resultList = state.resultList.filter { when (it) { is SearchResultUiState.SearchedStatus -> { it.status.status.id != statusId } else -> true } } ) } }, followStateUpdater = { _, _ -> } ) } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/search/hashtag/SearchHashtagViewModel.kt ================================================ package com.zhangke.fread.explore.screens.search.hashtag import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.TextString import com.zhangke.framework.controller.CommonLoadableController import com.zhangke.framework.controller.CommonLoadableUiState import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.Hashtag import com.zhangke.fread.status.model.PlatformLocator import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow open class SearchHashtagViewModel( private val statusProvider: StatusProvider, private val locator: PlatformLocator, ) : ViewModel() { private val _snackMessageFlow = MutableSharedFlow() val snackMessageFlow: SharedFlow get() = _snackMessageFlow private val loadableController = CommonLoadableController( viewModelScope, onPostSnackMessage = { launchInViewModel { _snackMessageFlow.emit(it) } }, ) val uiState: StateFlow> get() = loadableController.uiState private val _openScreenFlow = MutableSharedFlow() val openScreenFlow = _openScreenFlow.asSharedFlow() fun initQuery(query: String) { if (uiState.value.dataList.isNotEmpty()) return onRefresh(query) } fun onRefresh(query: String) { loadableController.onRefresh { statusProvider.searchEngine.searchHashtag(locator, query, null) } } fun onLoadMore(query: String) { val offset = uiState.value.dataList.size if (offset == 0) return loadableController.onLoadMore { statusProvider.searchEngine.searchHashtag(locator, query, offset) } } fun onHashtagClick(hashtag: Hashtag) { launchInViewModel { val screen = statusProvider.screenProvider.getTagTimelineScreen( locator, hashtag.name, hashtag.protocol ) ?: return@launchInViewModel _openScreenFlow.emit(screen) } } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/search/hashtag/SearchedHashtagTab.kt ================================================ package com.zhangke.fread.explore.screens.search.hashtag import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.LocalSnackbarHostState import com.zhangke.framework.composable.applyNestedScrollConnection import com.zhangke.framework.controller.CommonLoadableUiState import com.zhangke.framework.loadable.lazycolumn.LoadableInlineVideoLazyColumn import com.zhangke.framework.loadable.lazycolumn.rememberLoadableInlineVideoLazyColumnState import com.zhangke.framework.nav.BaseTab import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.TabOptions import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.Hashtag import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.ui.hashtag.HashtagUi import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf internal class SearchedHashtagTab( private val locator: PlatformLocator, private val query: String ) : BaseTab() { override val options: TabOptions @Composable get() = TabOptions( title = stringResource(LocalizedString.explorerSearchTabTitleHashtag), ) @Composable override fun Content() { super.Content() val backStack = LocalNavBackStack.currentOrThrow val viewModel = koinViewModel { parametersOf(locator) } val uiState by viewModel.uiState.collectAsState() val snackbarHostState = LocalSnackbarHostState.current LaunchedEffect(query) { viewModel.initQuery(query) } SearchedHashtagContent( uiState = uiState, onRefresh = { viewModel.onRefresh(query) }, onLoadMore = { viewModel.onLoadMore(query) }, onHashtagClick = viewModel::onHashtagClick, nestedScrollConnection = null, ) ConsumeFlow(viewModel.openScreenFlow) { backStack.add(it) } ConsumeSnackbarFlow( hostState = snackbarHostState, messageTextFlow = viewModel.snackMessageFlow ) } @OptIn(ExperimentalMaterialApi::class) @Composable private fun SearchedHashtagContent( uiState: CommonLoadableUiState, onRefresh: () -> Unit, onLoadMore: () -> Unit, onHashtagClick: (Hashtag) -> Unit, nestedScrollConnection: NestedScrollConnection? ) { val state = rememberLoadableInlineVideoLazyColumnState( onRefresh = onRefresh, onLoadMore = onLoadMore, ) LoadableInlineVideoLazyColumn( modifier = Modifier .fillMaxSize() .applyNestedScrollConnection(nestedScrollConnection), state = state, refreshing = uiState.refreshing, loadState = uiState.loadMoreState, ) { itemsIndexed(uiState.dataList) { _, item -> HashtagUi( modifier = Modifier.fillMaxWidth(), tag = item, onClick = onHashtagClick, ) } } } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/search/platform/SearchPlatformViewModel.kt ================================================ package com.zhangke.fread.explore.screens.search.platform import androidx.lifecycle.ViewModel import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.emitInViewModel import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.search.SearchedPlatform import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update open class SearchPlatformViewModel( private val statusProvider: StatusProvider, private val locator: PlatformLocator, private val query: String, ) : ViewModel() { private val _uiState = MutableStateFlow(SearchedPlatformUiState.default()) val uiState: StateFlow get() = _uiState private val _openScreenFlow = MutableSharedFlow() val openScreenFlow: SharedFlow get() = _openScreenFlow init { launchInViewModel { _uiState.update { it.copy(searching = true) } statusProvider.searchEngine .searchPlatform(locator, query.trim()) .collect { results -> _uiState.update { it.copy( searching = false, searchedList = it.searchedList + results, ) } } } } fun onContentClick(result: SearchedPlatform) { val baseUrl = when (result) { is SearchedPlatform.Platform -> result.platform.baseUrl is SearchedPlatform.Snapshot -> { FormalBaseUrl.parse(result.snapshot.domain) ?: return } } val protocol = when (result) { is SearchedPlatform.Platform -> result.platform.protocol is SearchedPlatform.Snapshot -> result.snapshot.protocol } statusProvider.screenProvider.getInstanceDetailScreen( baseUrl = baseUrl, protocol = protocol, locator = locator, )?.let { _openScreenFlow.emitInViewModel(it) } } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/search/platform/SearchedPlatformTab.kt ================================================ package com.zhangke.fread.explore.screens.search.platform import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.zhangke.framework.composable.ConsumeOpenScreenFlow import com.zhangke.framework.composable.DefaultLoading import com.zhangke.framework.nav.BaseTab import com.zhangke.framework.nav.TabOptions import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.search.SearchedPlatform import com.zhangke.fread.status.ui.source.SearchPlatformResultUi import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf internal class SearchedPlatformTab( private val locator: PlatformLocator, private val query: String, ) : BaseTab() { override val options: TabOptions @Composable get() = TabOptions( title = stringResource(LocalizedString.explorerSearchTabTitleServer), ) @Composable override fun Content() { super.Content() val viewModel = koinViewModel { parametersOf(locator, query) } val uiState by viewModel.uiState.collectAsState() SearchedSourcesContent( uiState = uiState, onContentClick = viewModel::onContentClick, ) ConsumeOpenScreenFlow(viewModel.openScreenFlow) } @Composable private fun SearchedSourcesContent( uiState: SearchedPlatformUiState, onContentClick: (SearchedPlatform) -> Unit, ) { Box(modifier = Modifier.fillMaxSize()) { if (uiState.searching && uiState.searchedList.isEmpty()) { DefaultLoading() } else { LazyColumn(modifier = Modifier.fillMaxSize()) { items(uiState.searchedList) { SearchPlatformResultUi(searchedResult = it, onContentClick = onContentClick) } } } } } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/search/platform/SearchedPlatformUiState.kt ================================================ package com.zhangke.fread.explore.screens.search.platform import com.zhangke.fread.status.search.SearchedPlatform data class SearchedPlatformUiState( val searchedList: List, val searching: Boolean, ){ companion object{ fun default() = SearchedPlatformUiState( searchedList = emptyList(), searching = false, ) } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/search/status/SearchStatusViewModel.kt ================================================ package com.zhangke.fread.explore.screens.search.status import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zhangke.framework.controller.CommonLoadableUiState import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.feeds.IInteractiveHandler import com.zhangke.fread.commonbiz.shared.feeds.InteractiveHandleResult import com.zhangke.fread.commonbiz.shared.feeds.InteractiveHandler import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.commonbiz.shared.utils.LoadableStatusController import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.model.updateStatus import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update class SearchStatusViewModel( private val statusProvider: StatusProvider, statusUpdater: StatusUpdater, statusUiStateAdapter: StatusUiStateAdapter, refactorToNewStatus: RefactorToNewStatusUseCase, val locator: PlatformLocator, ) : ViewModel(), IInteractiveHandler by InteractiveHandler( statusProvider = statusProvider, statusUpdater = statusUpdater, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, ) { private val loadStatusController = LoadableStatusController(viewModelScope) val uiState: StateFlow> get() = loadStatusController.uiState init { initInteractiveHandler( coroutineScope = viewModelScope, onInteractiveHandleResult = { result -> when (result) { is InteractiveHandleResult.UpdateStatus -> { loadStatusController.mutableUiState.update { state -> state.copy( dataList = state.dataList.updateStatus(result.status), ) } } is InteractiveHandleResult.DeleteStatus -> { loadStatusController.mutableUiState.update { state -> state.copy( dataList = state.dataList.filter { it.status.id != result.statusId }, ) } } is InteractiveHandleResult.UpdateFollowState -> { // no-op } } } ) } fun initQuery(query: String) { if (uiState.value.dataList.isNotEmpty()) return onRefresh(query) } fun onRefresh(query: String) { loadStatusController.onRefresh(locator) { statusProvider.searchEngine .searchStatus(locator, query, null) } } fun onLoadMore(query: String) { loadStatusController.onLoadMore(locator) { statusProvider.searchEngine.searchStatus(locator, query, it) } } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/screens/search/status/SearchedStatusTab.kt ================================================ package com.zhangke.fread.explore.screens.search.status import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.LocalSnackbarHostState import com.zhangke.framework.composable.applyNestedScrollConnection import com.zhangke.framework.controller.CommonLoadableUiState import com.zhangke.framework.loadable.lazycolumn.LoadableInlineVideoLazyColumn import com.zhangke.framework.loadable.lazycolumn.rememberLoadableInlineVideoLazyColumnState import com.zhangke.framework.nav.BaseTab import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.TabOptions import com.zhangke.fread.commonbiz.shared.composable.FeedsStatusNode import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.ui.ComposedStatusInteraction import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf internal class SearchedStatusTab( private val locator: PlatformLocator, private val query: String, ) : BaseTab() { override val options: TabOptions @Composable get() = TabOptions( title = stringResource(LocalizedString.explorerSearchTabTitleStatus), ) @Composable override fun Content() { super.Content() val backStack = LocalNavBackStack.currentOrThrow val viewModel = koinViewModel { parametersOf(locator) } val uiState by viewModel.uiState.collectAsState() LaunchedEffect(query) { viewModel.initQuery(query) } SearchStatusTabContent( uiState = uiState, composedStatusInteraction = viewModel.composedStatusInteraction, onRefresh = { viewModel.onRefresh(query) }, onLoadMore = { viewModel.onLoadMore(query) }, nestedScrollConnection = null, ) ConsumeFlow(viewModel.openScreenFlow) { backStack.add(it) } val snackbarHostState = LocalSnackbarHostState.currentOrThrow ConsumeSnackbarFlow(snackbarHostState, viewModel.errorMessageFlow) } @OptIn(ExperimentalMaterialApi::class) @Composable private fun SearchStatusTabContent( uiState: CommonLoadableUiState, composedStatusInteraction: ComposedStatusInteraction, onRefresh: () -> Unit, onLoadMore: () -> Unit, nestedScrollConnection: NestedScrollConnection?, ) { val state = rememberLoadableInlineVideoLazyColumnState( onRefresh = onRefresh, onLoadMore = onLoadMore, ) LoadableInlineVideoLazyColumn( modifier = Modifier .fillMaxSize() .applyNestedScrollConnection(nestedScrollConnection), state = state, refreshing = uiState.refreshing, loadState = uiState.loadMoreState, ) { itemsIndexed(uiState.dataList) { index, item -> FeedsStatusNode( modifier = Modifier.fillMaxWidth(), status = item, indexInList = index, composedStatusInteraction = composedStatusInteraction, ) } } } } ================================================ FILE: feature/explore/src/commonMain/kotlin/com/zhangke/fread/explore/usecase/BuildSearchResultUiStateUseCase.kt ================================================ package com.zhangke.fread.explore.usecase import com.zhangke.fread.common.status.model.SearchResultUiState import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.search.SearchResult class BuildSearchResultUiStateUseCase() { suspend operator fun invoke(locator: PlatformLocator, result: SearchResult): SearchResultUiState { return when (result) { is SearchResult.Author -> { SearchResultUiState.Author(locator, result.user) } is SearchResult.Platform -> { SearchResultUiState.Platform(locator, result.platform) } is SearchResult.SearchedStatus -> { SearchResultUiState.SearchedStatus(result.status) } is SearchResult.SearchedHashtag -> { SearchResultUiState.SearchedHashtag(locator, result.hashtag) } } } } ================================================ FILE: feature/feeds/.gitignore ================================================ /build ================================================ FILE: feature/feeds/build.gradle.kts ================================================ plugins { id("fread.project.feature.kmp") id("com.google.devtools.ksp") id("kotlin-parcelize") } android { namespace = "com.zhangke.fread.feeds" } kotlin { sourceSets { commonMain { dependencies { implementation(project(":framework")) implementation(project(":bizframework:status-provider")) implementation(project(":commonbiz:common")) implementation(project(":commonbiz:analytics")) implementation(project(":commonbiz:sharedscreen")) implementation(project(":commonbiz:status-ui")) implementation(compose.components.resources) implementation(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) implementation(libs.arrow.core) implementation(libs.jetbrains.lifecycle.viewmodel) implementation(libs.haze) implementation(libs.krouter.runtime) implementation(libs.androidx.room) implementation(libs.androidx.constraintlayout.compose.kmp) implementation(libs.auto.service.annotations) implementation(libs.uri.kmp) implementation(libs.androidx.paging.common) } } commonTest { dependencies { implementation(kotlin("test")) } } androidMain { dependencies { implementation(libs.androidx.core.ktx) implementation(libs.bundles.androidx.activity) implementation(libs.androidx.browser) } } } configureCommonMainKsp() } dependencies { kspAll(libs.androidx.room.compiler) kspAll(libs.auto.service.ksp) kspAll(libs.krouter.collecting.compiler) } compose { resources { publicResClass = false packageOfResClass = "com.zhangke.fread.feeds" generateResClass = always } } ================================================ FILE: feature/feeds/consumer-rules.pro ================================================ ================================================ FILE: feature/feeds/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: feature/feeds/src/androidMain/kotlin/com/zhangke/fread/feeds/pages/manager/importing/OpenDocumentContainer.android.kt ================================================ package com.zhangke.fread.feeds.pages.manager.importing import android.net.Uri import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.zhangke.framework.utils.PlatformUri import com.zhangke.framework.utils.toPlatformUri @Composable actual fun OpenDocumentContainer( onResult: (PlatformUri) -> Unit, content: @Composable OpenDocumentContainerScope.() -> Unit, ) { val selectedFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> uri?.toPlatformUri()?.let { onResult(it) } } val scope = remember { OpenDocumentContainerScope(selectedFileLauncher) } with(scope) { content() } } actual class OpenDocumentContainerScope( private val launcher: ManagedActivityResultLauncher, Uri?> ) { actual fun launch() { launcher.launch(arrayOf("*/*")) } } ================================================ FILE: feature/feeds/src/commonMain/composeResources/drawable/ic_home.xml ================================================ ================================================ FILE: feature/feeds/src/commonMain/composeResources/drawable/ic_import.xml ================================================ ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/FeedsNavEntryProvider.kt ================================================ package com.zhangke.fread.feeds import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.zhangke.framework.nav.NavEntryProvider import com.zhangke.fread.feeds.pages.manager.add.mixed.AddMixedFeedsScreen import com.zhangke.fread.feeds.pages.manager.add.mixed.AddMixedFeedsScreenNavKey import com.zhangke.fread.feeds.pages.manager.add.type.SelectContentTypeScreen import com.zhangke.fread.feeds.pages.manager.add.type.SelectContentTypeScreenNavKey import com.zhangke.fread.feeds.pages.manager.edit.EditMixedContentScreen import com.zhangke.fread.feeds.pages.manager.edit.EditMixedContentScreenNavKey import com.zhangke.fread.feeds.pages.manager.importing.ImportFeedsScreen import com.zhangke.fread.feeds.pages.manager.importing.ImportFeedsScreenNavKey import com.zhangke.fread.feeds.pages.manager.search.SearchSourceForAddScreen import com.zhangke.fread.feeds.pages.manager.search.SearchSourceForAddScreenNavKey import kotlinx.serialization.modules.PolymorphicModuleBuilder import kotlinx.serialization.modules.subclass import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf class FeedsNavEntryProvider : NavEntryProvider { override fun EntryProviderScope.build() { entry { SelectContentTypeScreen(koinViewModel()) } entry { AddMixedFeedsScreen(koinViewModel { parametersOf(null) }) } entry { ImportFeedsScreen(koinViewModel()) } entry { SearchSourceForAddScreen(koinViewModel()) } entry { key -> EditMixedContentScreen( koinViewModel { parametersOf(key.contentId) } ) } } override fun PolymorphicModuleBuilder.polymorph() { subclass(SelectContentTypeScreenNavKey::class) subclass(AddMixedFeedsScreenNavKey::class) subclass(ImportFeedsScreenNavKey::class) subclass(SearchSourceForAddScreenNavKey::class) subclass(EditMixedContentScreenNavKey::class) } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/FeedsScreenVisitor.kt ================================================ package com.zhangke.fread.feeds import androidx.navigation3.runtime.NavKey import com.zhangke.fread.commonbiz.shared.IFeedsScreenVisitor import com.zhangke.fread.feeds.pages.manager.add.type.SelectContentTypeScreenNavKey class FeedsScreenVisitor : IFeedsScreenVisitor { override fun getAddContentScreen(): NavKey { return SelectContentTypeScreenNavKey } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/composable/StatusSource.kt ================================================ package com.zhangke.fread.feeds.composable import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.rounded.AddCircleOutline import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.FreadDialog import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.common.browser.launchWebTabInApp import com.zhangke.fread.common.resources.logo import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.source.StatusSource import com.zhangke.fread.status.ui.utils.CardInfoSection import org.jetbrains.compose.resources.stringResource data class StatusSourceUiState( val source: StatusSource, val addEnabled: Boolean, val removeEnabled: Boolean, ) @Composable internal fun RemovableStatusSource( modifier: Modifier = Modifier, source: StatusSourceUiState, onClick: () -> Unit, onRemoveClick: () -> Unit, ) { StatusSourceNode( modifier = modifier, source = source, onClick = onClick, onRemoveClick = onRemoveClick, ) } @Composable internal fun StatusSourceNode( modifier: Modifier = Modifier, source: StatusSourceUiState, onClick: () -> Unit, onAddClick: (() -> Unit)? = null, onRemoveClick: (() -> Unit)? = null, ) { val browserLauncher = LocalActivityBrowserLauncher.current val coroutineScope = rememberCoroutineScope() CardInfoSection( modifier = modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), avatar = source.source.thumbnail, title = source.source.name, handle = source.source.handle, description = source.source.description, logo = rememberVectorPainter(source.source.protocol.logo), onClick = onClick, onUrlClick = { browserLauncher.launchWebTabInApp(coroutineScope, it) }, actions = { if (source.addEnabled) { IconButton( modifier = Modifier .size(24.dp) .padding(end = 8.dp), onClick = { onAddClick?.invoke() }, enabled = onAddClick != null, ) { Icon( painter = rememberVectorPainter(image = Icons.Rounded.AddCircleOutline), contentDescription = "Add", ) } } if (source.removeEnabled) { var showDeleteConfirmDialog by remember { mutableStateOf(false) } IconButton( modifier = Modifier.size(24.dp), onClick = { showDeleteConfirmDialog = true }, enabled = onRemoveClick != null, ) { Icon( painter = rememberVectorPainter(image = Icons.Default.Delete), contentDescription = "Remove", ) } if (showDeleteConfirmDialog) { FreadDialog( onDismissRequest = { showDeleteConfirmDialog = false }, contentText = stringResource(LocalizedString.feedsDeleteConfirmContent), onNegativeClick = { showDeleteConfirmDialog = false }, onPositiveClick = { showDeleteConfirmDialog = false onRemoveClick?.invoke() }, ) } } } ) } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/di/FeedsModule.kt ================================================ package com.zhangke.fread.feeds.di import com.zhangke.framework.nav.NavEntryProvider import com.zhangke.fread.commonbiz.shared.IFeedsScreenVisitor import com.zhangke.fread.feeds.FeedsNavEntryProvider import com.zhangke.fread.feeds.FeedsScreenVisitor import com.zhangke.fread.feeds.pages.home.ContentHomeViewModel import com.zhangke.fread.feeds.pages.home.feeds.MixedContentViewModel import com.zhangke.fread.feeds.pages.manager.add.mixed.AddMixedFeedsViewModel import com.zhangke.fread.feeds.pages.manager.add.type.SelectContentTypeViewModel import com.zhangke.fread.feeds.pages.manager.edit.EditMixedContentViewModel import com.zhangke.fread.feeds.pages.manager.importing.ImportFeedsViewModel import com.zhangke.fread.feeds.pages.manager.search.SearchSourceForAddViewModel import com.zhangke.fread.status.source.StatusSource import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.bind import org.koin.dsl.module val feedsModule = module { factoryOf(::FeedsNavEntryProvider) bind NavEntryProvider::class viewModelOf(::ContentHomeViewModel) viewModelOf(::MixedContentViewModel) viewModel { params -> AddMixedFeedsViewModel( statusProvider = get(), contentRepo = get(), onboardingComponent = get(), statusSource = params.getOrNull(), ) } viewModelOf(::EditMixedContentViewModel) viewModelOf(::ImportFeedsViewModel) viewModelOf(::SearchSourceForAddViewModel) viewModelOf(::SelectContentTypeViewModel) singleOf(::FeedsScreenVisitor) bind IFeedsScreenVisitor::class } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/home/ContentHomeUiState.kt ================================================ package com.zhangke.fread.feeds.pages.home import com.zhangke.framework.nav.Tab import com.zhangke.fread.status.model.FreadContent data class ContentHomeUiState( val currentPageIndex: Int, val loading: Boolean, val contentAndTabList: List>, ) { companion object { val default = ContentHomeUiState( currentPageIndex = 0, loading = true, contentAndTabList = emptyList(), ) } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/home/ContentHomeViewModel.kt ================================================ package com.zhangke.fread.feeds.pages.home import androidx.lifecycle.ViewModel import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.nav.Tab import com.zhangke.fread.common.account.ActiveAccountsSynchronizer import com.zhangke.fread.common.content.FreadContentRepo import com.zhangke.fread.common.deeplink.SelectedContentSwitcher import com.zhangke.fread.feeds.pages.home.feeds.MixedContentTab import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.content.MixedContent import com.zhangke.fread.status.model.FreadContent import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.update class ContentHomeViewModel( private val contentRepo: FreadContentRepo, private val statusProvider: StatusProvider, private val activeAccountsSynchronizer: ActiveAccountsSynchronizer, private val selectedContentSwitcher: SelectedContentSwitcher, ) : ViewModel() { private val _uiState = MutableStateFlow(ContentHomeUiState.default) val uiState: StateFlow = _uiState private val _switchPageFlow = MutableSharedFlow(1) val switchPageFlow: SharedFlow = _switchPageFlow init { launchInViewModel { _uiState.update { it.copy(loading = true) } val allContent = contentRepo.getAllContent() var currentPageIndex = 0 val lastActiveAccount = activeAccountsSynchronizer.activeAccountUriFlow.value if (!lastActiveAccount.isNullOrEmpty()) { allContent.indexOfFirst { it.accountUri?.toString() == lastActiveAccount } .takeIf { it >= 0 } ?.let { currentPageIndex = it } } _uiState.update { currentState -> currentState.copy( currentPageIndex = currentPageIndex, contentAndTabList = convertContentsToWithTab(allContent), loading = false, ) } activeAccountsSynchronizer.activeAccountUriFlow .mapNotNull { it?.takeIf { it.isNotEmpty() } } .collect { uri -> val activeIndex = _uiState.value.contentAndTabList.indexOfFirst { config -> config.first.accountUri?.toString() == uri } if (activeIndex >= 0 && activeIndex != _uiState.value.currentPageIndex) { _switchPageFlow.emit(activeIndex) } } } launchInViewModel { contentRepo.getAllContentFlow() .drop(1) .map { convertContentsToWithTab(it) } .collect { _uiState.update { currentState -> currentState.copy( currentPageIndex = currentState.currentPageIndex.coerceAtMost(it.size - 1), contentAndTabList = it, ) } } } launchInViewModel { selectedContentSwitcher.selectedContentFlow.collect { val targetIndex = _uiState.value.contentAndTabList.indexOfFirst { pair -> pair.first.id == it.id } if (targetIndex >= 0 && targetIndex != _uiState.value.currentPageIndex) { _switchPageFlow.emit(targetIndex) } } } } fun onCurrentPageChanged(currentPage: Int) { val currentState = _uiState.value if (currentPage == currentState.currentPageIndex) return _uiState.update { it.copy(currentPageIndex = currentPage) } launchInViewModel { _uiState.value .contentAndTabList .getOrNull(currentPage) ?.first ?.accountUri ?.toString() ?.let { activeAccountsSynchronizer.onAccountSelected(it) } } } private fun convertContentsToWithTab(contents: List): List> { return contents.mapIndexed { index, content -> content.convertToWithTab(index == contents.lastIndex) } } private fun FreadContent.convertToWithTab(isLatestTab: Boolean): Pair { if (this is MixedContent) { return this to MixedContentTab( configId = id, isLatestTab = isLatestTab, ) } return this to statusProvider.screenProvider.getContentScreen(this, isLatestTab) } fun onSwitchPageFlowUsed() { _switchPageFlow.resetReplayCache() selectedContentSwitcher.resetReplayCache() } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/home/FeedsContentHomeTab.kt ================================================ package com.zhangke.fread.feeds.pages.home import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.common.composable.EmptyContent import com.zhangke.fread.feeds.pages.manager.add.type.SelectContentTypeScreenNavKey import com.zhangke.fread.status.ui.common.LocalNestedTabConnection import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel @Composable fun FeedsContentHomeScreen() { val backStack = LocalNavBackStack.currentOrThrow val coroutineScope = rememberCoroutineScope() val viewModel: ContentHomeViewModel = koinViewModel() val uiState by viewModel.uiState.collectAsState() if (uiState.contentAndTabList.isEmpty()) { if (uiState.loading) { Box(modifier = Modifier.fillMaxSize()) } else { EmptyContent(modifier = Modifier.fillMaxSize()) { backStack.add(SelectContentTypeScreenNavKey) } } } else { val mainTabConnection = LocalNestedTabConnection.current val pagerState = rememberPagerState( initialPage = uiState.currentPageIndex.coerceAtLeast(0), pageCount = { uiState.contentAndTabList.size }, ) ConsumeFlow(mainTabConnection.switchToNextTabFlow) { if (pagerState.currentPage < pagerState.pageCount - 1) { coroutineScope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } } } ConsumeFlow(mainTabConnection.scrollToContentTabFlow) { content -> val index = uiState.contentAndTabList.indexOfFirst { it.first == content } if (index in 0 until pagerState.pageCount) { coroutineScope.launch { pagerState.animateScrollToPage(index) } } } ConsumeFlow(viewModel.switchPageFlow) { coroutineScope.launch { viewModel.onSwitchPageFlowUsed() pagerState.animateScrollToPage(it) } } val targetPage = pagerState.targetPage LaunchedEffect(targetPage) { viewModel.onCurrentPageChanged(targetPage) } val contentScrollInProgress by mainTabConnection.contentScrollInpProgress.collectAsState() HorizontalPager( state = pagerState, userScrollEnabled = !contentScrollInProgress, ) { pageIndex -> val currentScreen = uiState.contentAndTabList[pageIndex].second with(currentScreen) { Content() } } } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/home/FeedsHomeTab.kt ================================================ package com.zhangke.fread.feeds.pages.home import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.zhangke.framework.nav.Tab import com.zhangke.framework.nav.TabOptions import com.zhangke.fread.feeds.Res import com.zhangke.fread.feeds.ic_home import org.jetbrains.compose.resources.painterResource class FeedsHomeTab : Tab { override val options: TabOptions @Composable get() { val icon = painterResource(Res.drawable.ic_home) return remember { TabOptions( title = "Home", icon = icon, ) } } @Composable override fun Content() { FeedsContentHomeScreen() } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/home/feeds/MixedContentSubViewModel.kt ================================================ package com.zhangke.fread.feeds.pages.home.feeds import com.zhangke.framework.collections.updateItem import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.composable.toTextStringOrNull import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.lifecycle.SubViewModel import com.zhangke.framework.utils.LoadState import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.config.FreadConfigManager import com.zhangke.fread.common.content.FreadContentRepo import com.zhangke.fread.common.mixed.MixedStatusRepo import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.feeds.IInteractiveHandler import com.zhangke.fread.commonbiz.shared.feeds.InteractiveHandleResult import com.zhangke.fread.commonbiz.shared.feeds.InteractiveHandler import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.feeds.pages.manager.edit.EditMixedContentScreenNavKey import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.author.updateFollowingState import com.zhangke.fread.status.content.MixedContent import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.model.updateBlogAuthor import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class MixedContentSubViewModel( private val contentRepo: FreadContentRepo, private val mixedRepo: MixedStatusRepo, statusUpdater: StatusUpdater, private val freadConfigManager: FreadConfigManager, private val statusUiStateAdapter: StatusUiStateAdapter, private val statusProvider: StatusProvider, private val refactorToNewStatus: RefactorToNewStatusUseCase, private val configId: String, ) : SubViewModel(), IInteractiveHandler by InteractiveHandler( statusProvider = statusProvider, statusUpdater = statusUpdater, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, ) { private val _uiState = MutableStateFlow(MixedContentUiState.default()) val uiState = _uiState.asStateFlow() private var refreshJob: Job? = null private var loadMoreJob: Job? = null init { initInteractiveHandler( coroutineScope = viewModelScope, onInteractiveHandleResult = { interaction -> when (interaction) { is InteractiveHandleResult.UpdateStatus -> { _uiState.update { state -> state.copy( dataList = state.dataList .updateItem(interaction.status) { interaction.status } ) } mixedRepo.updateStatus(interaction.status) } is InteractiveHandleResult.DeleteStatus -> { _uiState.update { state -> state.copy( dataList = state.dataList .filter { it.status.id != interaction.statusId } ) } mixedRepo.deleteStatus(interaction.statusId) } is InteractiveHandleResult.UpdateFollowState -> { var updatedStatus: StatusUiState? = null _uiState.update { state -> state.copy( dataList = state.dataList.map { status -> if (status.status.intrinsicBlog.author.uri == interaction.userUri) { status.updateBlogAuthor { it.updateFollowingState(interaction.following) }.also { updatedStatus = it } } else { status } } ) } updatedStatus?.let { mixedRepo.updateStatus(it) } } } }, ) launchInViewModel { _uiState.update { it.copy(initializing = true) } val mixedContent = contentRepo.getContent(configId) as? MixedContent if (mixedContent == null) { _uiState.update { it.copy( pageError = IllegalStateException("Content($configId) does not exists!"), initializing = false, ) } } else { _uiState.update { it.copy(content = mixedContent) } launch { mixedRepo.refresh(mixedContent) } mixedRepo.getLocalStatusFlow(mixedContent) .collect { data -> _uiState.update { it.copy(dataList = data, initializing = false) } } } } launchInViewModel { contentRepo.getContentFlow(configId) .drop(1) .mapNotNull { it as? MixedContent } .collect { content -> delay(50) _uiState.update { it.copy(content = content) } mixedRepo.refresh(content) } } launchInViewModel { freadConfigManager.homeTabRefreshButtonVisibleFlow .collect { visible -> _uiState.update { it.copy(showRefreshButton = visible) } } } launchInViewModel { freadConfigManager.homeTabNextButtonVisibleFlow .collect { visible -> _uiState.update { it.copy(showNextButton = visible) } } } } fun onContentTitleClick() { val mixedContent = uiState.value.content ?: return launchInViewModel { mutableOpenScreenFlow.emit(EditMixedContentScreenNavKey(mixedContent.id)) } } fun onRefresh() { val content = uiState.value.content ?: return if (refreshJob?.isActive == true || loadMoreJob?.isActive == true) return refreshJob?.cancel() loadMoreJob?.cancel() refreshJob = launchInViewModel { _uiState.update { it.copy(refreshing = true) } mixedRepo.refresh(content) .onSuccess { _uiState.update { it.copy(refreshing = false) } }.onFailure { t -> _uiState.update { it.copy(refreshing = false) } mutableErrorMessageFlow.emitTextMessageFromThrowable(t) } } } fun onLoadMore() { val content = uiState.value.content ?: return if (refreshJob?.isActive == true || loadMoreJob?.isActive == true) return refreshJob?.cancel() loadMoreJob?.cancel() loadMoreJob = launchInViewModel { _uiState.update { it.copy(loadMoreState = LoadState.Loading) } mixedRepo.loadMoreStatus(content) .onSuccess { _uiState.update { it.copy(loadMoreState = LoadState.Idle) } }.onFailure { t -> _uiState.update { it.copy(loadMoreState = LoadState.Failed(t.toTextStringOrNull())) } mutableErrorMessageFlow.emitTextMessageFromThrowable(t) } } } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/home/feeds/MixedContentTab.kt ================================================ package com.zhangke.fread.feeds.pages.home.feeds import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.ConsumeOpenScreenFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.LocalContentPadding import com.zhangke.framework.composable.ToolbarTokens import com.zhangke.framework.composable.noRippleClick import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.Tab import com.zhangke.framework.nav.TabOptions import com.zhangke.fread.commonbiz.shared.composable.FeedsContent import com.zhangke.fread.status.ui.ComposedStatusInteraction import com.zhangke.fread.status.ui.common.ContentToolbar import com.zhangke.fread.status.ui.common.LocalNestedTabConnection import com.zhangke.fread.status.ui.common.doubleTapToScrollTop import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel internal class MixedContentTab( private val configId: String, private val isLatestTab: Boolean, ) : Tab { override val options: TabOptions? @Composable get() = null @Composable override fun Content() { val snackBarHostState = rememberSnackbarHostState() val viewModel = koinViewModel().getSubViewModel(configId) val uiState by viewModel.uiState.collectAsState() ConsumeSnackbarFlow(snackBarHostState, viewModel.errorMessageFlow) ConsumeOpenScreenFlow(viewModel.openScreenFlow) MixedContentUi( uiState = uiState, snackBarHostState = snackBarHostState, composedStatusInteraction = viewModel.composedStatusInteraction, onTitleClick = viewModel::onContentTitleClick, onRefresh = viewModel::onRefresh, onLoadMore = viewModel::onLoadMore, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun MixedContentUi( uiState: MixedContentUiState, snackBarHostState: SnackbarHostState, onTitleClick: () -> Unit, onRefresh: () -> Unit, onLoadMore: () -> Unit, composedStatusInteraction: ComposedStatusInteraction, ) { val mainTabConnection = LocalNestedTabConnection.current val coroutineScope = rememberCoroutineScope() val topBarState = rememberTopAppBarState() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topBarState) Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { ContentToolbar( modifier = Modifier.doubleTapToScrollTop { coroutineScope.launch { mainTabConnection.scrollToTop() } }, scrollBehavior = scrollBehavior, title = uiState.content?.name.orEmpty(), showNextIcon = !isLatestTab && uiState.showNextButton, showRefreshButton = uiState.showRefreshButton, account = null, showAccountInfo = false, onMenuClick = { coroutineScope.launch { mainTabConnection.openDrawer() } }, onNextClick = { coroutineScope.launch { mainTabConnection.switchToNextTab() } }, onRefreshClick = { coroutineScope.launch { mainTabConnection.scrollToTop() onRefresh() } }, onTitleClick = onTitleClick, onDoubleClick = { coroutineScope.launch { mainTabConnection.scrollToTop() } } ) }, snackbarHost = { SnackbarHost( modifier = Modifier.padding(bottom = 68.dp), hostState = snackBarHostState, ) }, ) { paddings -> CompositionLocalProvider( LocalContentPadding provides paddings ) { Box(modifier = Modifier.fillMaxSize()) { val nestedTabConnection = LocalNestedTabConnection.current FeedsContent( feeds = uiState.dataList, refreshing = uiState.refreshing, loadMoreState = uiState.loadMoreState, showPagingLoadingPlaceholder = uiState.initializing, pageErrorContent = uiState.pageError, newStatusNotifyFlow = null, onRefresh = onRefresh, onLoadMore = onLoadMore, composedStatusInteraction = composedStatusInteraction, observeScrollToTopEvent = true, onScrollToTopConsumed = { topBarState.contentOffset = 0F topBarState.heightOffset = 0F }, onImmersiveEvent = { if (it) { mainTabConnection.openImmersiveMode(coroutineScope) } else { mainTabConnection.closeImmersiveMode(coroutineScope) } }, onScrollInProgress = { nestedTabConnection.updateContentScrollInProgress(it) }, ) } } } } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/home/feeds/MixedContentUiState.kt ================================================ package com.zhangke.fread.feeds.pages.home.feeds import com.zhangke.framework.utils.LoadState import com.zhangke.fread.status.content.MixedContent import com.zhangke.fread.status.model.StatusUiState data class MixedContentUiState( val content: MixedContent?, val dataList: List, val initializing: Boolean, val refreshing: Boolean, val loadMoreState: LoadState, val pageError: Throwable?, val showRefreshButton: Boolean, val showNextButton: Boolean, ) { companion object { fun default(): MixedContentUiState { return MixedContentUiState( content = null, dataList = emptyList(), initializing = true, refreshing = false, loadMoreState = LoadState.Idle, pageError = null, showRefreshButton = false, showNextButton = false, ) } } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/home/feeds/MixedContentViewModel.kt ================================================ package com.zhangke.fread.feeds.pages.home.feeds import com.zhangke.framework.lifecycle.ContainerViewModel import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.config.FreadConfigManager import com.zhangke.fread.common.content.FreadContentRepo import com.zhangke.fread.common.mixed.MixedStatusRepo import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider class MixedContentViewModel( private val contentRepo: FreadContentRepo, private val mixedRepo: MixedStatusRepo, private val statusUpdater: StatusUpdater, private val freadConfigManager: FreadConfigManager, private val statusUiStateAdapter: StatusUiStateAdapter, private val statusProvider: StatusProvider, private val refactorToNewStatus: RefactorToNewStatusUseCase, ) : ContainerViewModel() { override fun createSubViewModel(params: Params): MixedContentSubViewModel { return MixedContentSubViewModel( contentRepo = contentRepo, mixedRepo = mixedRepo, statusUpdater = statusUpdater, freadConfigManager = freadConfigManager, statusUiStateAdapter = statusUiStateAdapter, statusProvider = statusProvider, configId = params.configId, refactorToNewStatus = refactorToNewStatus, ) } fun getSubViewModel(configId: String): MixedContentSubViewModel { val params = Params(configId) return obtainSubViewModel(params) } class Params(val configId: String) : SubViewModelParams() { override val key: String get() = configId.toString() } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/manager/add/mixed/AddMixedFeedsScreen.kt ================================================ package com.zhangke.fread.feeds.pages.manager.add.mixed import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.IconButtonStyle import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.StyledIconButton import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.composable.snackbarHost import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.common.utils.LocalToastHelper import com.zhangke.fread.feeds.Res import com.zhangke.fread.feeds.composable.RemovableStatusSource import com.zhangke.fread.feeds.composable.StatusSourceUiState import com.zhangke.fread.feeds.ic_import import com.zhangke.fread.feeds.pages.manager.importing.ImportFeedsScreenNavKey import com.zhangke.fread.feeds.pages.manager.search.SearchSourceForAddScreenNavKey import com.zhangke.fread.localization.LocalizedString import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource /** * 添加混合 Feeds 页面 */ @Serializable object AddMixedFeedsScreenNavKey : NavKey @Composable fun AddMixedFeedsScreen(viewModel: AddMixedFeedsViewModel) { val toastHelper = LocalToastHelper.current val backStack = LocalNavBackStack.currentOrThrow val snackbarHostState = rememberSnackbarHostState() FeedsManager( uiState = viewModel.uiState.collectAsState().value, snackbarHostState = snackbarHostState, onBackClick = backStack::removeLastOrNull, onAddSourceClick = { backStack.add(SearchSourceForAddScreenNavKey) }, onImportClick = { backStack.add(ImportFeedsScreenNavKey) }, onConfirmClick = { viewModel.onConfirmClick() }, onNameInputValueChanged = viewModel::onSourceNameInput, onRemoveSourceClick = { viewModel.onRemoveSource(it) }, ) ConsumeFlow(SearchSourceForAddScreenNavKey.sourceSelectedFlow.flow) { viewModel.onAddSource(it) } ConsumeSnackbarFlow(snackbarHostState, viewModel.errorMessageFlow) ConsumeFlow(viewModel.addContentSuccessFlow) { toastHelper.showToast(getString(LocalizedString.addContentSuccessSnackbar)) backStack.removeLastOrNull() } } @Composable private fun FeedsManager( uiState: AddMixedFeedsUiState, snackbarHostState: SnackbarHostState, onBackClick: () -> Unit, onAddSourceClick: () -> Unit, onImportClick: () -> Unit, onConfirmClick: () -> Unit, onNameInputValueChanged: (String) -> Unit, onRemoveSourceClick: (item: StatusSourceUiState) -> Unit, ) { Scaffold( topBar = { Toolbar( title = stringResource(LocalizedString.addFeedsPageTitle), onBackClick = onBackClick, actions = { SimpleIconButton( modifier = Modifier.rotate(180F), onClick = onImportClick, imageVector = vectorResource(Res.drawable.ic_import), contentDescription = "Import", ) SimpleIconButton( onClick = onConfirmClick, imageVector = Icons.Default.Check, contentDescription = "Add", ) } ) }, snackbarHost = snackbarHost(snackbarHostState), ) { paddings -> Column( modifier = Modifier .fillMaxSize() .padding(paddings) ) { Box(modifier = Modifier.height(32.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { OutlinedTextField( modifier = Modifier .padding(start = 16.dp, end = 16.dp) .weight(1F), value = uiState.sourceName, maxLines = 1, label = { Text(text = stringResource(LocalizedString.addFeedsPageFeedsNameLabel)) }, placeholder = { Text(text = stringResource(LocalizedString.addFeedsPageFeedsNameHint)) }, onValueChange = { onNameInputValueChanged(it.take(uiState.maxNameLength)) }, ) StyledIconButton( modifier = Modifier.padding(end = 8.dp), imageVector = Icons.Default.Add, style = IconButtonStyle.STANDARD, onClick = onAddSourceClick, ) } if (uiState.sourceList.isEmpty()) { Text( modifier = Modifier .padding(top = 32.dp) .align(Alignment.CenterHorizontally) .clickable { onAddSourceClick() }, text = stringResource(LocalizedString.addFeedsPageFeedsEmpty), style = MaterialTheme.typography.labelLarge, ) } else { LazyColumn( modifier = Modifier.padding(top = 32.dp), contentPadding = PaddingValues(bottom = 32.dp), ) { items(uiState.sourceList) { item -> RemovableStatusSource( modifier = Modifier.fillMaxWidth(), source = item, onClick = {}, onRemoveClick = { onRemoveSourceClick(item) }, ) } } } } } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/manager/add/mixed/AddMixedFeedsUiState.kt ================================================ package com.zhangke.fread.feeds.pages.manager.add.mixed import com.zhangke.fread.feeds.composable.StatusSourceUiState data class AddMixedFeedsUiState( val maxNameLength: Int, val sourceList: List, val sourceName: String, ) ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/manager/add/mixed/AddMixedFeedsViewModel.kt ================================================ package com.zhangke.fread.feeds.pages.manager.add.mixed import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zhangke.framework.collections.container import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.textOf import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.ktx.map import com.zhangke.fread.common.content.FreadContentRepo import com.zhangke.fread.common.onboarding.OnboardingComponent import com.zhangke.fread.feeds.composable.StatusSourceUiState import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.content.MixedContent import com.zhangke.fread.status.source.StatusSource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.update import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid class AddMixedFeedsViewModel( private val statusProvider: StatusProvider, private val contentRepo: FreadContentRepo, private val onboardingComponent: OnboardingComponent, private val statusSource: StatusSource? = null ) : ViewModel() { private val viewModelState = MutableStateFlow(initialViewModelState()) val uiState: StateFlow = viewModelState.map(viewModelScope) { it.toUiState() } private val _errorMessageFlow = MutableSharedFlow() val errorMessageFlow: Flow = _errorMessageFlow.asSharedFlow() private val _addContentSuccessFlow = MutableSharedFlow() val addContentSuccessFlow: SharedFlow get() = _addContentSuccessFlow init { onboardingComponent.clearState() launchInViewModel { val initAccountList = statusProvider.accountManager.getAllLoggedAccount() statusProvider.accountManager .getAllAccountFlow() .collect { currentAccountList -> if (initAccountList.isNotEmpty()) return@collect if (currentAccountList.isEmpty()) return@collect onConfirmClick() } } } fun onAddSource(source: StatusSource) { launchInViewModel { val sourceList = mutableListOf() sourceList.addAll(viewModelState.value.sourceList) if (!sourceList.container { it.uri == source.uri }) { sourceList += source } viewModelState.update { it.copy(sourceList = sourceList) } } } fun onRemoveSource(source: StatusSourceUiState) { viewModelState.update { state -> state.copy( sourceList = state.sourceList.filter { it.uri != source.source.uri } ) } } fun onSourceNameInput(name: String) { viewModelState.update { it.copy(sourceName = name) } } fun onConfirmClick() { launchInViewModel { val currentState = viewModelState.value if (currentState.sourceName.isEmpty()) { _errorMessageFlow.emit(textOf(LocalizedString.addFeedsPageEmptyNameTips)) return@launchInViewModel } if (contentRepo.checkNameExist(currentState.sourceName)) { _errorMessageFlow.emit(textOf(LocalizedString.addFeedsPageEmptyNameExist)) return@launchInViewModel } val sourceList = currentState.sourceList if (sourceList.isEmpty()) { _errorMessageFlow.emit(textOf(LocalizedString.addFeedsPageEmptySourceTips)) return@launchInViewModel } performAddContent() } } @OptIn(ExperimentalUuidApi::class) private fun performAddContent() { val currentState = viewModelState.value val sourceUriList = currentState.sourceList.map { it.uri } val sourceName = currentState.sourceName launchInViewModel { val order = contentRepo.getMaxOrder() + 1 val contentConfig = MixedContent( id = Uuid.random().toHexString(), order = order, name = sourceName, sourceUriList = sourceUriList, ) contentRepo.insertContent(contentConfig) _addContentSuccessFlow.emit(Unit) onboardingComponent.onboardingSuccess() } } private fun initialViewModelState(): AddSourceViewModelState { return AddSourceViewModelState( sourceList = if (statusSource == null) emptyList() else listOf(statusSource), sourceName = statusSource?.name.orEmpty(), ) } private fun AddSourceViewModelState.toUiState(): AddMixedFeedsUiState { return AddMixedFeedsUiState( sourceList = sourceList.map { it.toUiState() }, sourceName = sourceName, maxNameLength = 8, ) } private fun StatusSource.toUiState( addEnabled: Boolean = false, removeEnabled: Boolean = true, ): StatusSourceUiState { return StatusSourceUiState( source = this, addEnabled = addEnabled, removeEnabled = removeEnabled, ) } } internal data class AddSourceViewModelState( val sourceList: List, val sourceName: String, ) ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/manager/add/type/SelectContentTypeScreen.kt ================================================ package com.zhangke.fread.feeds.pages.manager.add.type import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column 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.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeOpenScreenFlow import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.common.resources.blueskyDescription import com.zhangke.fread.common.resources.blueskyName import com.zhangke.fread.common.resources.mastodonDescription import com.zhangke.fread.common.resources.mastodonName import com.zhangke.fread.common.resources.mixedDescription import com.zhangke.fread.common.resources.mixedName import com.zhangke.fread.feeds.Res import com.zhangke.fread.feeds.img_add_content_bsky import com.zhangke.fread.feeds.img_add_content_mastodon import com.zhangke.fread.feeds.img_add_content_mixed import com.zhangke.fread.feeds.pages.manager.add.mixed.AddMixedFeedsScreenNavKey import com.zhangke.fread.localization.LocalizedString import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @Serializable object SelectContentTypeScreenNavKey : NavKey @OptIn(ExperimentalMaterial3Api::class) @Composable fun SelectContentTypeScreen(viewModel: SelectContentTypeViewModel) { val backStack = LocalNavBackStack.currentOrThrow val mastodonCardColor = Color(0xFFFFA600) val blueskyCardColor = Color(0xFF0080FF) val mixedCardColor = Color(0xFF3AD06B) Scaffold( topBar = { Toolbar( title = stringResource(LocalizedString.feedsSelectTypeScreenTitle), onBackClick = backStack::removeLastOrNull, ) }, modifier = Modifier.fillMaxSize(), ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { ContentTypeCard( modifier = Modifier.padding(top = 22.dp).fillMaxWidth(), cardColor = mastodonCardColor, title = mastodonName(), description = mastodonDescription(), onCardClick = viewModel::onMastodonClick, contentImage = { Image( modifier = Modifier.fillMaxHeight() .align(Alignment.CenterEnd), painter = painterResource(Res.drawable.img_add_content_mastodon), contentDescription = null, contentScale = ContentScale.FillHeight, ) }, ) ContentTypeCard( modifier = Modifier.fillMaxWidth(), cardColor = blueskyCardColor, title = blueskyName(), description = blueskyDescription(), onCardClick = viewModel::onBlueskyClick, contentImage = { Image( modifier = Modifier.fillMaxHeight() .align(Alignment.CenterEnd), painter = painterResource(Res.drawable.img_add_content_bsky), contentDescription = null, contentScale = ContentScale.FillHeight, ) }, ) ContentTypeCard( modifier = Modifier.fillMaxWidth(), cardColor = mixedCardColor, title = mixedName(), description = mixedDescription(), onCardClick = { backStack.add(AddMixedFeedsScreenNavKey) }, contentImage = { Image( modifier = Modifier.fillMaxHeight() .align(Alignment.CenterEnd), painter = painterResource(Res.drawable.img_add_content_mixed), contentDescription = null, contentScale = ContentScale.FillHeight, ) }, ) } } ConsumeOpenScreenFlow(viewModel.openScreenFlow) ConsumeFlow(viewModel.finishPageFlow) { backStack.removeLastOrNull() } LaunchedEffect(Unit) { viewModel.onPageResumed(this) } } @Composable private fun ContentTypeCard( modifier: Modifier, cardColor: Color, contentImage: @Composable BoxScope.() -> Unit, title: String, description: String, onCardClick: () -> Unit, ) { Card( modifier = modifier.padding(horizontal = 16.dp) .fillMaxWidth() .height(180.dp), onClick = onCardClick, colors = CardDefaults.cardColors( containerColor = cardColor, contentColor = MaterialTheme.colorScheme.inverseOnSurface, ), shape = RoundedCornerShape(16.dp), ) { Box(modifier = Modifier.fillMaxSize()) { contentImage() Column( modifier = Modifier.fillMaxWidth() .align(Alignment.BottomStart) .drawBehind { drawRect( brush = Brush.verticalGradient( colors = listOf(Color.Transparent, cardColor.copy(alpha = 0.9F)) ) ) }, ) { Text( modifier = Modifier.padding(start = 16.dp), text = title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, ) Text( modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 22.dp) .fillMaxWidth(fraction = 0.9F), textAlign = TextAlign.Start, text = description, style = MaterialTheme.typography.labelMedium, ) } } } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/manager/add/type/SelectContentTypeViewModel.kt ================================================ package com.zhangke.fread.feeds.pages.manager.add.type import androidx.lifecycle.ViewModel import androidx.navigation3.runtime.NavKey import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.common.onboarding.OnboardingComponent import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.createActivityPubProtocol import com.zhangke.fread.status.model.createBlueskyProtocol import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch class SelectContentTypeViewModel( private val onboardingComponent: OnboardingComponent, private val statusProvider: StatusProvider, ) : ViewModel() { private val _openScreenFlow = MutableSharedFlow() val openScreenFlow = _openScreenFlow.asSharedFlow() private val _finishPageFlow = MutableSharedFlow() val finishPageFlow = _finishPageFlow.asSharedFlow() init { onboardingComponent.clearState() } fun onPageResumed(uiScope: CoroutineScope) { uiScope.launch { onboardingComponent.onboardingFinishedFlow.collect { _finishPageFlow.emit(Unit) } } } fun onMastodonClick() { statusProvider.screenProvider .getAddContentScreen(createActivityPubProtocol()) .let { launchInViewModel { _openScreenFlow.emit(it) } } } fun onBlueskyClick() { statusProvider.screenProvider .getAddContentScreen(createBlueskyProtocol()) .let { launchInViewModel { _openScreenFlow.emit(it) } } } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/manager/edit/EditMixedContentScreen.kt ================================================ package com.zhangke.fread.feeds.pages.manager.edit import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.AlertConfirmDialog import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.FreadDialog import com.zhangke.framework.composable.LoadableLayout import com.zhangke.framework.composable.LoadableState import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.composable.successDataOrNull import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.feeds.composable.RemovableStatusSource import com.zhangke.fread.feeds.composable.StatusSourceUiState import com.zhangke.fread.feeds.pages.manager.search.SearchSourceForAddScreenNavKey import com.zhangke.fread.localization.LocalizedString import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable data class EditMixedContentScreenNavKey(val contentId: String) : NavKey @Composable fun EditMixedContentScreen(viewModel: EditMixedContentViewModel) { val backStack = LocalNavBackStack.currentOrThrow val uiState by viewModel.uiState.collectAsState() EditFeedsScreenContent( uiState = uiState, onRemoveSourceClick = viewModel::onSourceDelete, onEditNameClick = viewModel::onEditName, onBackClick = backStack::removeLastOrNull, onAddSourceClick = { backStack.add(SearchSourceForAddScreenNavKey) }, onDeleteClick = viewModel::onDeleteFeeds, ) ConsumeFlow(SearchSourceForAddScreenNavKey.sourceSelectedFlow.flow) { viewModel.onAddSource(it) } ConsumeFlow(viewModel.finishScreenFlow) { backStack.removeLastOrNull() } } @Composable private fun EditFeedsScreenContent( uiState: LoadableState, onRemoveSourceClick: (StatusSourceUiState) -> Unit, onEditNameClick: (String) -> Unit, onBackClick: () -> Unit, onAddSourceClick: () -> Unit, onDeleteClick: () -> Unit, ) { val snackbarHostState = rememberSnackbarHostState() val errorMessage = uiState.successDataOrNull()?.errorMessage?.take(180) if (errorMessage.isNullOrEmpty().not()) { LaunchedEffect(errorMessage) { snackbarHostState.showSnackbar(errorMessage.orEmpty()) } } Scaffold( topBar = { EditFeedsScreenTopBar( uiState = uiState, onEditNameClick = onEditNameClick, onBackClick = onBackClick, onDeleteClick = onDeleteClick, ) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, floatingActionButton = { if (uiState.isSuccess) { FloatingActionButton( modifier = Modifier.padding(bottom = 32.dp), containerColor = MaterialTheme.colorScheme.surface, onClick = onAddSourceClick, shape = CircleShape, ) { Icon( painter = rememberVectorPainter(image = Icons.Default.Add), contentDescription = "Add Source", ) } } }, ) { paddings -> LoadableLayout( modifier = Modifier .fillMaxSize() .padding(paddings), state = uiState, ) { uiState -> LazyColumn(modifier = Modifier.fillMaxSize()) { items(uiState.sourceList) { item -> RemovableStatusSource( modifier = Modifier.fillMaxWidth(), onClick = {}, source = item, onRemoveClick = { onRemoveSourceClick(item) } ) } } } } } @Composable private fun EditFeedsScreenTopBar( uiState: LoadableState, onEditNameClick: (String) -> Unit, onBackClick: () -> Unit, onDeleteClick: () -> Unit, ) { var showEditNameDialog by remember { mutableStateOf(false) } var showDeleteConfirmDialog by remember { mutableStateOf(false) } val configName = uiState.successDataOrNull()?.name.orEmpty() Toolbar( title = configName, onBackClick = onBackClick, actions = { IconButton( onClick = { showEditNameDialog = true }, ) { Icon( painter = rememberVectorPainter(Icons.Default.Edit), contentDescription = "Edit Name", ) } IconButton( onClick = { showDeleteConfirmDialog = true }, ) { Icon( painter = rememberVectorPainter(Icons.Default.Delete), contentDescription = "Delete Feeds", ) } }, ) val loadedUiState = uiState.successDataOrNull() if (loadedUiState != null && showEditNameDialog) { var inputtedText by remember { mutableStateOf(configName) } FreadDialog( title = stringResource(LocalizedString.feedsMixedConfigEditNewNameDialogTitle), onDismissRequest = { showEditNameDialog = false }, onNegativeClick = { showEditNameDialog = false }, onPositiveClick = { showEditNameDialog = false onEditNameClick(inputtedText) }, content = { OutlinedTextField( modifier = Modifier .padding(16.dp) .fillMaxWidth(), value = inputtedText, onValueChange = { inputtedText = it }, label = { Text(text = stringResource(LocalizedString.feedsMixedConfigEditNewNameDialogLabel)) } ) }, ) } if (showDeleteConfirmDialog) { AlertConfirmDialog( content = stringResource(LocalizedString.feedsMixedConfigEditDeleteContentDialogMessage), onDismissRequest = { showDeleteConfirmDialog = false }, onConfirm = onDeleteClick, ) } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/manager/edit/EditMixedContentUiState.kt ================================================ package com.zhangke.fread.feeds.pages.manager.edit import com.zhangke.fread.feeds.composable.StatusSourceUiState data class EditMixedContentUiState( val name: String, val sourceList: List, val errorMessage: String? = null, ) ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/manager/edit/EditMixedContentViewModel.kt ================================================ package com.zhangke.fread.feeds.pages.manager.edit import androidx.lifecycle.ViewModel import com.zhangke.framework.composable.LoadableState import com.zhangke.framework.composable.requireSuccessData import com.zhangke.framework.composable.successDataOrNull import com.zhangke.framework.composable.updateOnSuccess import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.common.content.FreadContentRepo import com.zhangke.fread.feeds.composable.StatusSourceUiState import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.content.MixedContent import com.zhangke.fread.status.source.StatusSource import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow class EditMixedContentViewModel( private val configRepo: FreadContentRepo, private val statusProvider: StatusProvider, private val configId: String, ) : ViewModel() { private val _uiState = MutableStateFlow(LoadableState.loading()) val uiState: StateFlow> = _uiState.asStateFlow() private val _finishScreenFlow = MutableSharedFlow() val finishScreenFlow: SharedFlow = _finishScreenFlow.asSharedFlow() init { loadFeedsDetail() } fun onSourceDelete(source: StatusSourceUiState) { launchInViewModel { val newSourceList = _uiState.value .requireSuccessData() .sourceList .filter { it != source } updateSourceList(newSourceList) _uiState.updateOnSuccess { it.copy(sourceList = newSourceList) } } } fun onDeleteFeeds() { launchInViewModel { configRepo.delete(configId) _finishScreenFlow.emit(Unit) } } fun onAddSource(source: StatusSource) { val sourceList = _uiState.value.successDataOrNull()?.sourceList?.toMutableList() ?: return launchInViewModel { if (sourceList.any { it.source.uri == source.uri }) return@launchInViewModel sourceList += StatusSourceUiState( source = source, addEnabled = true, removeEnabled = false ) updateSourceList(sourceList) loadFeedsDetail() } } private suspend fun updateSourceList(sourceList: List) { getMixedContent()?.copy(sourceUriList = sourceList.map { it.source.uri }) ?.let { configRepo.insertContent(it) } } private fun loadFeedsDetail() { launchInViewModel { val contentConfig = configRepo.getContent(configId) if (contentConfig == null) { _uiState.emit(LoadableState.failed(IllegalArgumentException("Unknown Content of $configId"))) return@launchInViewModel } if (contentConfig !is MixedContent) { _uiState.emit(LoadableState.failed(IllegalArgumentException("Only for Mixed Content"))) return@launchInViewModel } val sourceList = contentConfig.sourceUriList.mapNotNull { statusProvider.statusSourceResolver.resolveSourceByUri(it).getOrNull() }.map { source -> StatusSourceUiState( source = source, addEnabled = false, removeEnabled = true, ) } _uiState.emit( LoadableState.success(EditMixedContentUiState(contentConfig.name, sourceList)) ) } } fun onEditName(newName: String) { if (newName == _uiState.value.requireSuccessData().name) return launchInViewModel { val exists = configRepo.checkNameExist(newName) if (exists) { _uiState.updateOnSuccess { it.copy(errorMessage = "$newName exists!") } return@launchInViewModel } getMixedContent()?.copy(name = newName)?.let { configRepo.insertContent(it) } _uiState.updateOnSuccess { it.copy(name = newName) } } } private suspend fun getMixedContent(): MixedContent? { return configRepo.getContent(configId) as? MixedContent } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/manager/importing/ImportFeedsScreen.kt ================================================ package com.zhangke.fread.feeds.pages.manager.importing import androidx.compose.foundation.clickable 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.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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Save import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState 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.vector.rememberVectorPainter import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import com.zhangke.framework.composable.BackHandler import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.FreadDialog import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.utils.PlatformUri import com.zhangke.fread.common.utils.LocalPlatformUriHelper import com.zhangke.fread.common.utils.LocalToastHelper import com.zhangke.fread.localization.LocalizedString import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource @Serializable object ImportFeedsScreenNavKey : NavKey @Composable fun ImportFeedsScreen(viewModel: ImportFeedsViewModel) { val toastHelper = LocalToastHelper.current val backStack = LocalNavBackStack.currentOrThrow val uiState by viewModel.uiState.collectAsState() var showBackDialog by remember { mutableStateOf(false) } fun onBackRequest() { if (uiState.selectedFileUri != null || uiState.sourceList.isNotEmpty()) { showBackDialog = true } else { backStack.removeLastOrNull() } } if (showBackDialog) { FreadDialog( onDismissRequest = { showBackDialog = false }, title = stringResource(LocalizedString.alert), contentText = stringResource(LocalizedString.feedsImportBackDialogMessage), onNegativeClick = { showBackDialog = false }, onPositiveClick = { backStack.removeLastOrNull() } ) } BackHandler(true) { onBackRequest() } ImportFeedsContent( uiState = uiState, onBackClick = ::onBackRequest, onFileSelected = viewModel::onFileSelected, onImportClick = { viewModel.onImportClick() }, onGroupDelete = viewModel::onGroupDelete, onSourceDelete = viewModel::onSourceDelete, onSaveClick = viewModel::onSaveClick, retryImportClick = viewModel::retryImportClick, ) ConsumeFlow(viewModel.saveSuccessFlow) { toastHelper.showToast(getString(LocalizedString.addContentSuccessSnackbar)) backStack.removeLastOrNull() } } @Composable private fun ImportFeedsContent( uiState: ImportFeedsUiState, onFileSelected: (PlatformUri) -> Unit, onBackClick: () -> Unit, onImportClick: () -> Unit, onGroupDelete: (ImportSourceGroup) -> Unit, onSourceDelete: (ImportSourceGroup, ImportingSource) -> Unit, retryImportClick: (ImportSourceGroup, ImportingSource) -> Unit, onSaveClick: () -> Unit, ) { val platformUriHelper = LocalPlatformUriHelper.current Scaffold( topBar = { Toolbar( title = stringResource(LocalizedString.feedsImportPageTitle), onBackClick = onBackClick, actions = { SimpleIconButton( onClick = onSaveClick, imageVector = Icons.Default.Save, contentDescription = "Save", ) } ) }, ) { innerPadding -> Column( modifier = Modifier.padding(innerPadding), ) { Row( modifier = Modifier .padding(16.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { OpenDocumentContainer( onResult = onFileSelected, ) { Card( modifier = Modifier .weight(1F) .clickable { launch() }, ) { Box( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), ) { val prettyFileUri = remember(uiState.selectedFileUri) { uiState.selectedFileUri?.let { platformUriHelper.queryFileName(it) } } Text( modifier = Modifier.align(Alignment.Center), text = prettyFileUri ?: stringResource(LocalizedString.feedsImportPageHint), overflow = TextOverflow.Clip, maxLines = 1, fontSize = 12.sp, ) } } } Button( modifier = Modifier.padding(start = 16.dp), onClick = onImportClick, enabled = uiState.selectedFileUri != null, ) { Text( text = stringResource(LocalizedString.feedsImportButton) ) } } if (uiState.errorMessage.isNullOrEmpty().not()) { Text( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), text = uiState.errorMessage.orEmpty(), textAlign = TextAlign.Start, color = MaterialTheme.colorScheme.error, ) } ImportGroupList( itemList = uiState.importingUiItems, onGroupDelete = onGroupDelete, onSourceDelete = onSourceDelete, retryImportClick = retryImportClick, ) } } } @Composable private fun ImportGroupList( itemList: List, onGroupDelete: (ImportSourceGroup) -> Unit, onSourceDelete: (ImportSourceGroup, ImportingSource) -> Unit, retryImportClick: (ImportSourceGroup, ImportingSource) -> Unit, ) { val lazyListState = rememberLazyListState() LazyColumn( modifier = Modifier.fillMaxSize(), state = lazyListState, ) { items(itemList) { item -> when (item) { is ImportingUiItem.Group -> { ImportGroupItem( group = item.group, onGroupDelete = onGroupDelete, ) } is ImportingUiItem.Source -> { ImportSourceItem( group = item.group, source = item.source, onSourceDelete = onSourceDelete, retryImportClick = retryImportClick, ) } } } } } @Composable private fun ImportGroupItem( group: ImportSourceGroup, onGroupDelete: (ImportSourceGroup) -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, top = 16.dp, bottom = 8.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = Modifier, text = group.title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleLarge, ) Spacer(modifier = Modifier.weight(1F)) var showDeleteDialog by remember { mutableStateOf(false) } Text( text = "${group.children.size} sources", style = MaterialTheme.typography.labelMedium, ) Spacer(modifier = Modifier.width(8.dp)) SimpleIconButton( onClick = { showDeleteDialog = true }, imageVector = Icons.Default.Delete, contentDescription = "Delete group", ) if (showDeleteDialog) { FreadDialog( onDismissRequest = { showDeleteDialog = false }, contentText = stringResource(LocalizedString.feedsDeleteConfirmContent), onNegativeClick = { showDeleteDialog = false }, onPositiveClick = { showDeleteDialog = false onGroupDelete(group) }, ) } } } @Composable private fun ImportSourceItem( group: ImportSourceGroup, source: ImportingSource, onSourceDelete: (ImportSourceGroup, ImportingSource) -> Unit, retryImportClick: (ImportSourceGroup, ImportingSource) -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .padding(start = 32.dp, top = 4.dp, bottom = 4.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Column( modifier = Modifier.weight(1F), ) { Text( modifier = Modifier, text = source.title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, ) if (source is ImportingSource.Failure) { Text( text = source.errorMessage, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelSmall, maxLines = 1, ) } } Spacer(modifier = Modifier.width(6.dp)) when (source) { is ImportingSource.Importing -> { CircularProgressIndicator( modifier = Modifier.size(16.dp) ) } is ImportingSource.Success -> { Icon( imageVector = Icons.Default.Check, tint = MaterialTheme.colorScheme.primary, contentDescription = null, ) } is ImportingSource.Pending -> { Text(text = "waiting...") } is ImportingSource.Failure -> { Icon( modifier = Modifier.clickable { retryImportClick(group, source) }, painter = rememberVectorPainter(image = Icons.Default.Refresh), contentDescription = "Retry", ) } } Spacer(modifier = Modifier.width(8.dp)) var showDeleteDialog by remember { mutableStateOf(false) } SimpleIconButton( onClick = { showDeleteDialog = true }, imageVector = Icons.Default.Delete, contentDescription = "Delete source", ) if (showDeleteDialog) { FreadDialog( onDismissRequest = { showDeleteDialog = false }, contentText = stringResource(LocalizedString.feedsDeleteConfirmContent), onNegativeClick = { showDeleteDialog = false }, onPositiveClick = { showDeleteDialog = false onSourceDelete(group, source) }, ) } } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/manager/importing/ImportFeedsUiState.kt ================================================ package com.zhangke.fread.feeds.pages.manager.importing import com.zhangke.framework.utils.PlatformUri import com.zhangke.fread.status.uri.FormalUri data class ImportFeedsUiState( val selectedFileUri: PlatformUri?, val sourceList: List, val errorMessage: String? = null, ) { val importingUiItems: List by lazy { createUiItems() } private fun createUiItems(): List { return sourceList.flatMap { group -> listOf(ImportingUiItem.Group(group)) + group.children.map { ImportingUiItem.Source(group, it) } } } companion object { val default = ImportFeedsUiState( selectedFileUri = null, sourceList = emptyList(), errorMessage = null, ) } } sealed interface ImportingUiItem { data class Group(val group: ImportSourceGroup) : ImportingUiItem data class Source(val group: ImportSourceGroup, val source: ImportingSource) : ImportingUiItem } data class ImportSourceGroup( val title: String, val children: List, ) sealed interface ImportingSource { val title: String val url: String data class Importing( override val title: String, override val url: String, ) : ImportingSource data class Success( override val title: String, override val url: String, val formalUri: FormalUri, ) : ImportingSource data class Failure( override val title: String, override val url: String, val errorMessage: String, ) : ImportingSource data class Pending( override val title: String, override val url: String ) : ImportingSource } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/manager/importing/ImportFeedsViewModel.kt ================================================ package com.zhangke.fread.feeds.pages.manager.importing import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zhangke.framework.collections.container import com.zhangke.framework.collections.remove import com.zhangke.framework.network.SimpleUri import com.zhangke.framework.opml.OpmlOutline import com.zhangke.framework.opml.OpmlParser import com.zhangke.framework.utils.PlatformUri import com.zhangke.fread.analytics.reportToLogger import com.zhangke.fread.common.content.FreadContentRepo import com.zhangke.fread.common.utils.PlatformUriHelper import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.content.MixedContent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid class ImportFeedsViewModel( private val statusProvider: StatusProvider, private val contentRepo: FreadContentRepo, private val platformUriHelper: PlatformUriHelper, ) : ViewModel() { private val _uiState = MutableStateFlow(ImportFeedsUiState.default) val uiState = _uiState.asStateFlow() private val _saveSuccessFlow = MutableSharedFlow() val saveSuccessFlow = _saveSuccessFlow.asSharedFlow() private var importingJob: Job? = null fun onFileSelected(uri: PlatformUri) { _uiState.value = _uiState.value.copy( selectedFileUri = uri, sourceList = emptyList(), ) } fun onImportClick() { if (importingJob?.isActive == true) return if (uiState.value.sourceList.isNotEmpty()) return _uiState.update { it.copy(errorMessage = null) } importingJob = viewModelScope.launch { val sourceGroup = parseOpmlToGroup(uiState.value.selectedFileUri!!) _uiState.update { it.copy(sourceList = sourceGroup) } sourceGroup.forEach { importGroup(it) } } } private suspend fun importGroup(group: ImportSourceGroup) { if (checkGroupDeleted(group)) return supervisorScope { group.children.map { source -> async { importSource(group, source) } }.awaitAll() } } private suspend fun importSource(group: ImportSourceGroup, source: ImportingSource) { if (checkSourceDeleted(group, source)) return if (source !is ImportingSource.Pending && source !is ImportingSource.Failure) return updateSourceUiState(group, ImportingSource.Importing(source.title, source.url)) statusProvider.statusSourceResolver.resolveRssSource(source.url) .onSuccess { updateSourceUiState( group, ImportingSource.Success(source.title, source.url, it.uri) ) }.onFailure { updateSourceUiState( group, ImportingSource.Failure(source.title, source.url, it.message ?: "Unknown error") ) } } private fun updateSourceUiState(group: ImportSourceGroup, source: ImportingSource) { _uiState.update { state -> state.copy( sourceList = state.sourceList.map { item -> if (item.title == group.title) { item.copy( children = item.children.map { child -> if (child.url == source.url) { source } else { child } }) } else { item } } ) } } fun onGroupDelete(group: ImportSourceGroup) { _uiState.update { state -> state.copy( sourceList = state.sourceList.remove { it == group } ) } } fun onSourceDelete(group: ImportSourceGroup, source: ImportingSource) { _uiState.update { state -> state.copy( sourceList = state.sourceList.map { item -> if (item == group) { item.copy(children = item.children.remove { it == source }) } else { item } } ) } } fun onSaveClick() { if (importingJob?.isActive == true) return viewModelScope.launch { val mixedContentList = _uiState.value .sourceList .mapNotNull { it.toMixedContent() } if (mixedContentList.isEmpty()) return@launch contentRepo.insertAll(mixedContentList) _saveSuccessFlow.emit(Unit) } } fun retryImportClick(group: ImportSourceGroup, source: ImportingSource) { viewModelScope.launch { importSource(group, source) } } private fun checkGroupDeleted(group: ImportSourceGroup): Boolean { val currentList = _uiState.value.sourceList return !currentList.container { it.title == group.title } } private fun checkSourceDeleted(group: ImportSourceGroup, source: ImportingSource): Boolean { val currentList = _uiState.value.sourceList val existGroup = currentList.firstOrNull { it.title == group.title } ?: return true return !existGroup.children.container { it.url == source.url } } @OptIn(ExperimentalUuidApi::class) private suspend fun ImportSourceGroup.toMixedContent(): MixedContent? { val successChildren = this.children.filterIsInstance() if (successChildren.isEmpty()) return null return MixedContent( id = Uuid.random().toHexString(), order = contentRepo.getMaxOrder() + 1, name = this.title, sourceUriList = successChildren.map { it.formalUri }, ) } private suspend fun parseOpmlToGroup(uri: PlatformUri): List { return parseOpml(uri).groupBy { it.title.ifEmpty { "Unknown" } } .map { entry -> ImportSourceGroup( title = entry.key, children = entry.value.flatMap { it.allChildren() }.map { it.toSource() } ) } } private fun OpmlOutline.toSource(): ImportingSource { return ImportingSource.Pending( title = title.ifEmpty { convertSimpleNameFromUrl(xmlUrl) }, url = xmlUrl ) } private fun convertSimpleNameFromUrl(url: String): String { if (url.isBlank()) return "Unknown" return SimpleUri.parse(url)?.host ?: "Unknown" } private fun OpmlOutline.allChildren(): List { val list = mutableListOf() if (xmlUrl.isNotBlank()) { list.add(this) } children.forEach { list.addAll(it.allChildren()) } return list } private suspend fun parseOpml( uri: PlatformUri, ): List = withContext(Dispatchers.IO) { var xmlDocument = "" return@withContext try { xmlDocument = platformUriHelper.readBytes(uri)?.decodeToString() ?: "" OpmlParser.parse(xmlDocument) } catch (e: Throwable) { _uiState.update { it.copy(errorMessage = e.message) } reportToLogger("OPML_IMPORT_ERROR") { put("errorMessage", e.message.orEmpty()) put("trace", e.stackTraceToString()) put("document", xmlDocument.take(90000)) } emptyList() } } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/manager/importing/OpenDocumentContainer.kt ================================================ package com.zhangke.fread.feeds.pages.manager.importing import androidx.compose.runtime.Composable import com.zhangke.framework.utils.PlatformUri @Composable expect fun OpenDocumentContainer( onResult: (PlatformUri) -> Unit, content: @Composable OpenDocumentContainerScope.() -> Unit ) expect class OpenDocumentContainerScope { fun launch() } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/manager/search/SearchForAddUiState.kt ================================================ package com.zhangke.fread.feeds.pages.manager.search import com.zhangke.fread.feeds.composable.StatusSourceUiState data class SearchForAddUiState( val query: String, val searching: Boolean, val searchedList: List, ) { companion object { fun default(): SearchForAddUiState { return SearchForAddUiState( query = "", searching = false, searchedList = emptyList(), ) } } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/manager/search/SearchSourceForAddScreen.kt ================================================ package com.zhangke.fread.feeds.pages.manager.search 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.ScreenEventFlow import com.zhangke.framework.utils.HighlightTextBuildUtil import com.zhangke.fread.feeds.composable.StatusSourceNode import com.zhangke.fread.feeds.composable.StatusSourceUiState import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.source.StatusSource import kotlinx.serialization.Serializable import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @Serializable object SearchSourceForAddScreenNavKey : NavKey { val sourceSelectedFlow = ScreenEventFlow() } @Composable fun SearchSourceForAddScreen(viewModel: SearchSourceForAddViewModel) { val backStack = LocalNavBackStack.currentOrThrow val coroutineScope = rememberCoroutineScope() val uiState by viewModel.uiState.collectAsState() val snackBarHostState = rememberSnackbarHostState() SearchSourceForAdd( uiState = uiState, snackBarHostState = snackBarHostState, onBackClick = backStack::removeLastOrNull, onQueryChanged = viewModel::onQueryChanged, onSearchClick = viewModel::onSearchClick, onAddClick = { coroutineScope.launch { SearchSourceForAddScreenNavKey.sourceSelectedFlow.emit(it.source) backStack.removeLastOrNull() } }, ) ConsumeSnackbarFlow(snackBarHostState, viewModel.snackbarMessageFlow) } @Composable private fun SearchSourceForAdd( uiState: SearchForAddUiState, snackBarHostState: SnackbarHostState, onBackClick: () -> Unit, onQueryChanged: (String) -> Unit, onSearchClick: () -> Unit, onAddClick: (StatusSourceUiState) -> Unit, ) { Scaffold( modifier = Modifier.fillMaxSize(), topBar = { Toolbar( title = stringResource(LocalizedString.searchFeedsTitle), onBackClick = onBackClick, ) }, snackbarHost = { SnackbarHost(snackBarHostState) }, ) { innerPadding -> Column( modifier = Modifier.fillMaxSize() .padding(innerPadding), ) { OutlinedTextField( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, top = 18.dp, end = 16.dp), value = uiState.query, onValueChange = onQueryChanged, maxLines = 1, keyboardOptions = KeyboardOptions.Default.copy( imeAction = ImeAction.Search ), placeholder = { Text( text = stringResource(LocalizedString.searchFeedsTitleHint), style = MaterialTheme.typography.labelMedium, ) }, keyboardActions = KeyboardActions( onSearch = { onSearchClick() } ), trailingIcon = { if (uiState.searching) { CircularProgressIndicator( modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary, ) } else { SimpleIconButton( onClick = onSearchClick, imageVector = Icons.Default.Search, contentDescription = "Search", ) } }, ) Text( modifier = Modifier .padding(start = 16.dp, top = 12.dp, end = 16.dp), text = buildInputLabelText(), lineHeight = 1.5.em, style = MaterialTheme.typography.labelMedium, ) LazyColumn( modifier = Modifier .fillMaxSize() .padding(top = 16.dp), ) { items(uiState.searchedList) { item -> StatusSourceNode( modifier = Modifier, onClick = { onAddClick(item) }, source = item, ) } } } } } @Composable private fun buildInputLabelText(): AnnotatedString { return buildAnnotatedString { append( HighlightTextBuildUtil.buildHighlightText( text = stringResource(LocalizedString.preAddFeedsInputLabel1), fontWeight = FontWeight.Bold, ) ) append( HighlightTextBuildUtil.buildHighlightText( text = stringResource(LocalizedString.preAddFeedsInputLabel2), fontWeight = FontWeight.Bold, ) ) } } ================================================ FILE: feature/feeds/src/commonMain/kotlin/com/zhangke/fread/feeds/pages/manager/search/SearchSourceForAddViewModel.kt ================================================ package com.zhangke.fread.feeds.pages.manager.search import androidx.lifecycle.ViewModel import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.feeds.composable.StatusSourceUiState import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.source.StatusSource import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update class SearchSourceForAddViewModel( private val statusProvider: StatusProvider, ) : ViewModel() { private val _uiState = MutableStateFlow(SearchForAddUiState.default()) val uiState = _uiState.asStateFlow() private val _snackbarMessageFlow = MutableSharedFlow() val snackbarMessageFlow = _snackbarMessageFlow private var searchJob: Job? = null fun onSearchClick() { performSearch() } fun onQueryChanged(query: String) { _uiState.update { it.copy(query = query) } if (query.isEmpty()) { _uiState.update { it.copy(searchedList = emptyList()) } return } performSearch() } private fun performSearch() { if (searchJob?.isActive == true) searchJob?.cancel() searchJob = launchInViewModel { _uiState.update { it.copy(searching = true) } doSearch(_uiState.value.query) .onSuccess { list -> _uiState.update { it.copy(searchedList = list, searching = false) } }.onFailure { e -> _uiState.update { it.copy(searching = false) } _snackbarMessageFlow.emitTextMessageFromThrowable(e) } } } private suspend fun doSearch(query: String): Result> { return statusProvider.searchEngine.searchSourceNoToken(query) .map { list -> list.map { it.toUiState() } } } private fun StatusSource.toUiState(): StatusSourceUiState { return StatusSourceUiState( source = this, addEnabled = false, removeEnabled = false, ) } } ================================================ FILE: feature/feeds/src/iosMain/kotlin/com/zhangke/fread/feeds/pages/manager/importing/OpenDocumentContainer.ios.kt ================================================ package com.zhangke.fread.feeds.pages.manager.importing import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.zhangke.framework.utils.PlatformUri @Composable actual fun OpenDocumentContainer( onResult: (PlatformUri) -> Unit, content: @Composable OpenDocumentContainerScope.() -> Unit, ) { val scope = remember { OpenDocumentContainerScope() } with(scope) { content() } } actual class OpenDocumentContainerScope { actual fun launch() { TODO("Not yet implemented") } } ================================================ FILE: feature/notifications/.gitignore ================================================ /build ================================================ FILE: feature/notifications/build.gradle.kts ================================================ plugins { id("fread.project.feature.kmp") id("com.google.devtools.ksp") alias(libs.plugins.room) } android { namespace = "com.zhangke.fread.feature.notifications" } kotlin { sourceSets { commonMain { dependencies { implementation(project(":framework")) implementation(project(":bizframework:status-provider")) implementation(project(":commonbiz:common")) implementation(project(":commonbiz:analytics")) implementation(project(":commonbiz:sharedscreen")) implementation(project(":commonbiz:status-ui")) implementation(compose.components.resources) implementation(libs.jetbrains.lifecycle.viewmodel) implementation(libs.androidx.paging.common) implementation(libs.auto.service.annotations) implementation(libs.krouter.runtime) implementation(libs.androidx.room) implementation(libs.haze) implementation(libs.haze.materials) } } commonTest { dependencies { implementation(kotlin("test")) } } androidMain { dependencies { implementation(libs.androidx.core.ktx) implementation(libs.bundles.androidx.activity) implementation(libs.androidx.browser) } } } configureCommonMainKsp() } dependencies { kspAll(libs.auto.service.ksp) kspAll(libs.krouter.collecting.compiler) kspAll(libs.androidx.room.compiler) } compose { resources { publicResClass = false packageOfResClass = "com.zhangke.fread.feature.notifications" generateResClass = always } } room { schemaDirectory("$projectDir/schemas") } ================================================ FILE: feature/notifications/consumer-rules.pro ================================================ ================================================ FILE: feature/notifications/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: feature/notifications/src/androidMain/kotlin/com/zhangke/fread/feature/message/di/NotificationsAndroidModule.kt ================================================ package com.zhangke.fread.feature.message.di import androidx.room.Room import com.zhangke.fread.feature.message.repo.notification.NotificationsDatabase import org.koin.android.ext.koin.androidContext import org.koin.core.module.Module actual fun Module.createPlatformModule() { single { Room.databaseBuilder( androidContext(), NotificationsDatabase::class.java, NotificationsDatabase.DB_NAME, ).addMigrations(NotificationsDatabase.MIGRATION_1_2).build() } } ================================================ FILE: feature/notifications/src/commonMain/composeResources/drawable/ic_notification_tab.xml ================================================ ================================================ FILE: feature/notifications/src/commonMain/kotlin/com/zhangke/fread/feature/message/NotificationElements.kt ================================================ package com.zhangke.fread.feature.message object NotificationElements { const val SWITCH_ACCOUNT = "notificationSwitchAccount" } ================================================ FILE: feature/notifications/src/commonMain/kotlin/com/zhangke/fread/feature/message/NotificationsNavEntryProvider.kt ================================================ package com.zhangke.fread.feature.message import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.zhangke.framework.nav.NavEntryProvider import kotlinx.serialization.modules.PolymorphicModuleBuilder class NotificationsNavEntryProvider : NavEntryProvider { override fun EntryProviderScope.build() { } override fun PolymorphicModuleBuilder.polymorph() { } } ================================================ FILE: feature/notifications/src/commonMain/kotlin/com/zhangke/fread/feature/message/di/NotificationsModule.kt ================================================ package com.zhangke.fread.feature.message.di import com.zhangke.framework.nav.NavEntryProvider import com.zhangke.fread.feature.message.NotificationsNavEntryProvider import com.zhangke.fread.feature.message.repo.notification.NotificationsRepo import com.zhangke.fread.feature.message.screens.home.NotificationsHomeViewModel import com.zhangke.fread.feature.message.screens.notification.NotificationContainerViewModel import org.koin.core.module.Module import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.bind import org.koin.dsl.module val notificationsModule = module { createPlatformModule() factoryOf(::NotificationsNavEntryProvider) bind NavEntryProvider::class singleOf(::NotificationsRepo) viewModelOf(::NotificationsHomeViewModel) viewModelOf(::NotificationContainerViewModel) } expect fun Module.createPlatformModule() ================================================ FILE: feature/notifications/src/commonMain/kotlin/com/zhangke/fread/feature/message/repo/notification/NotificationsDatabase.kt ================================================ package com.zhangke.fread.feature.message.repo.notification import androidx.room.ConstructedBy import androidx.room.Dao import androidx.room.Database import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.RoomDatabase import androidx.room.RoomDatabaseConstructor import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.SQLiteConnection import androidx.sqlite.execSQL import com.zhangke.fread.common.db.converts.FormalUriConverter import com.zhangke.fread.common.db.converts.StatusNotificationConverter import com.zhangke.fread.status.notification.StatusNotification import com.zhangke.fread.status.uri.FormalUri private const val DB_VERSION = 2 private const val TABLE_NAME = "notifications" @Entity(tableName = TABLE_NAME) data class NotificationEntity( @PrimaryKey val notificationId: String, val accountUri: FormalUri, val notification: StatusNotification, ) @Dao interface NotificationsDao { @Query("SELECT * FROM $TABLE_NAME WHERE notificationId = :id") suspend fun queryById(id: String): NotificationEntity? @Query("SELECT * FROM $TABLE_NAME WHERE accountUri = :accountUri") suspend fun queryByAccountUri(accountUri: FormalUri): List @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entity: NotificationEntity) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(list: List) @Query("DELETE FROM $TABLE_NAME WHERE accountUri = :accountUri") suspend fun delete(accountUri: FormalUri) } @TypeConverters( FormalUriConverter::class, StatusNotificationConverter::class, ) @Database(entities = [NotificationEntity::class], version = DB_VERSION, exportSchema = false) @ConstructedBy(NotificationsDatabaseConstructor::class) abstract class NotificationsDatabase : RoomDatabase() { abstract fun notificationsDao(): NotificationsDao companion object { internal const val DB_NAME = "all_accounts_notifications_1.db" val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(connection: SQLiteConnection) { connection.execSQL("DELETE FROM $TABLE_NAME") } } } } // The Room compiler generates the `actual` implementations. @Suppress("NO_ACTUAL_FOR_EXPECT") expect object NotificationsDatabaseConstructor : RoomDatabaseConstructor { override fun initialize(): NotificationsDatabase } ================================================ FILE: feature/notifications/src/commonMain/kotlin/com/zhangke/fread/feature/message/repo/notification/NotificationsRepo.kt ================================================ package com.zhangke.fread.feature.message.repo.notification import com.zhangke.fread.status.notification.StatusNotification import com.zhangke.fread.status.uri.FormalUri class NotificationsRepo( database: NotificationsDatabase, ) { private val dao = database.notificationsDao() suspend fun getNotifications( accountUri: FormalUri, ): List { return dao.queryByAccountUri(accountUri).map { it.notification } } suspend fun replaceNotifications( accountUri: FormalUri, notifications: List, ) { val entities = notifications.map { it.toEntity(accountUri) } dao.delete(accountUri) dao.insert(entities) } suspend fun insertNotification( accountUri: FormalUri, notifications: List, ) { val entities = notifications.map { it.toEntity(accountUri) } dao.insert(entities) } suspend fun updateNotification(accountUri: FormalUri, notification: StatusNotification) { dao.insert(notification.toEntity(accountUri)) } private fun StatusNotification.toEntity( accountUri: FormalUri, ): NotificationEntity { return NotificationEntity( notificationId = this.id, accountUri = accountUri, notification = this, ) } } ================================================ FILE: feature/notifications/src/commonMain/kotlin/com/zhangke/fread/feature/message/screens/home/NotificationScreen.kt ================================================ package com.zhangke.fread.feature.message.screens.home import androidx.compose.foundation.background 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.fillMaxSize 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.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.zhangke.framework.blur.applyBlurEffect import com.zhangke.framework.blur.blurEffectContainerColor import com.zhangke.framework.composable.LocalContentPadding import com.zhangke.framework.composable.LocalSnackbarHostState import com.zhangke.framework.composable.SingleRowTopAppBar import com.zhangke.framework.composable.TopAppBarColors import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.noRippleClick import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.composable.updateTopPadding import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.utils.pxToDp import com.zhangke.fread.common.composable.EmptyContent import com.zhangke.fread.common.composable.EmptyContentType import com.zhangke.fread.commonbiz.shared.LocalModuleScreenVisitor import com.zhangke.fread.feature.message.screens.notification.LocalNotificationTabConsumeStatusBarInsets import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.ui.BlogAuthorAvatar import com.zhangke.fread.status.ui.common.SelectAccountDialog import com.zhangke.fread.status.ui.richtext.FreadRichText import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @Composable fun NotificationScreen() { val backstack = LocalNavBackStack.currentOrThrow val feedsScreenVisitor = LocalModuleScreenVisitor.current.feedsScreenVisitor val viewModel: NotificationsHomeViewModel = koinViewModel() val uiState by viewModel.uiState.collectAsState() NotificationsHomeScreenContent( uiState = uiState, onAccountSelected = viewModel::onAccountSelected, onAddClick = { backstack.add(feedsScreenVisitor.getAddContentScreen()) }, ) } @Composable private fun NotificationsHomeScreenContent( uiState: NotificationsHomeUiState, onAccountSelected: (LoggedAccount) -> Unit, onAddClick: () -> Unit, ) { val density = LocalDensity.current val snackbarHost = rememberSnackbarHostState() val showTopBar = uiState.tabs.size > 1 && uiState.selectedAccount != null Box( modifier = Modifier.fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { var topBarHeight: Dp by remember { mutableStateOf(0.dp) } if (uiState.tabs.isEmpty()) { EmptyContent( modifier = Modifier.fillMaxSize(), type = EmptyContentType.Message, onClick = onAddClick, ) } else { Box(modifier = Modifier.fillMaxSize()) { val pagerState = rememberPagerState { uiState.tabs.size } LaunchedEffect(uiState) { val index = uiState.accountList.indexOf(uiState.selectedAccount) if (index >= 0) { pagerState.scrollToPage(index) } } CompositionLocalProvider( LocalSnackbarHostState provides snackbarHost, LocalContentPadding provides updateTopPadding( if (showTopBar) topBarHeight else 0.dp ), LocalNotificationTabConsumeStatusBarInsets provides !showTopBar, ) { HorizontalPager( modifier = Modifier.fillMaxSize(), state = pagerState, userScrollEnabled = false, ) { pageIndex -> with(uiState.tabs[pageIndex]) { Content() } } } } } if (showTopBar) { NotificationTopBar( account = uiState.selectedAccount, accountList = uiState.accountList, onAccountSelected = onAccountSelected, onHeightChanged = { topBarHeight = it }, ) } SnackbarHost( hostState = snackbarHost, modifier = Modifier.align(Alignment.BottomCenter) .padding(bottom = LocalContentPadding.current.calculateBottomPadding() + 16.dp), ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun NotificationTopBar( account: LoggedAccount, accountList: List, onAccountSelected: (LoggedAccount) -> Unit, onHeightChanged: (Dp) -> Unit, ) { val density = LocalDensity.current val containerColor = MaterialTheme.colorScheme.surface SingleRowTopAppBar( modifier = Modifier.applyBlurEffect(containerColor = containerColor) .onSizeChanged { onHeightChanged(it.height.pxToDp(density)) }, title = { Text( text = stringResource(LocalizedString.notificationTabTitle), ) }, height = 48.dp, colors = TopAppBarColors.default( containerColor = blurEffectContainerColor(true, containerColor), ), actions = { var showSelectAccountPopup by remember { mutableStateOf(false) } Box(modifier = Modifier.padding(end = 8.dp)) { Row( modifier = Modifier.noRippleClick { showSelectAccountPopup = !showSelectAccountPopup }, verticalAlignment = Alignment.CenterVertically, ) { BlogAuthorAvatar( modifier = Modifier.size(32.dp), imageUrl = account.avatar, ) Spacer(modifier = Modifier.width(6.dp)) Column { FreadRichText( modifier = Modifier, richText = account.humanizedName, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = account.prettyHandle, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } Icon( imageVector = Icons.Default.ArrowDropDown, contentDescription = "Select Account", ) } if (showSelectAccountPopup) { SelectAccountDialog( accountList = accountList, selectedAccounts = listOf(account), onDismissRequest = { showSelectAccountPopup = false }, onAccountClicked = onAccountSelected, ) } } }, ) } ================================================ FILE: feature/notifications/src/commonMain/kotlin/com/zhangke/fread/feature/message/screens/home/NotificationsHomeUiState.kt ================================================ package com.zhangke.fread.feature.message.screens.home import com.zhangke.framework.nav.Tab import com.zhangke.fread.feature.message.screens.notification.NotificationTab import com.zhangke.fread.status.account.LoggedAccount data class NotificationsHomeUiState( val selectedAccount: LoggedAccount? = null, val accountList: List, ) { val tabs: List = accountList.map { NotificationTab(it) } } ================================================ FILE: feature/notifications/src/commonMain/kotlin/com/zhangke/fread/feature/message/screens/home/NotificationsHomeViewModel.kt ================================================ package com.zhangke.fread.feature.message.screens.home import androidx.lifecycle.ViewModel import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.common.account.ActiveAccountsSynchronizer import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.account.LoggedAccount import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.update class NotificationsHomeViewModel( private val statusProvider: StatusProvider, private val activeAccountsSynchronizer: ActiveAccountsSynchronizer, ) : ViewModel() { private val _uiState = MutableStateFlow( NotificationsHomeUiState( selectedAccount = null, accountList = emptyList(), ) ) val uiState = _uiState.asStateFlow() init { launchInViewModel { statusProvider.accountManager .getAllAccountFlow() .collect { accounts -> var selectedAccount = _uiState.value.selectedAccount if (selectedAccount == null) { val lastActiveAccountUri = activeAccountsSynchronizer.activeAccountUriFlow.value if (!lastActiveAccountUri.isNullOrEmpty()) { selectedAccount = accounts.firstOrNull { it.uri.toString() == lastActiveAccountUri } } } if (selectedAccount == null) { selectedAccount = accounts.firstOrNull() } _uiState.update { it.copy( accountList = accounts, selectedAccount = selectedAccount, ) } } } launchInViewModel { activeAccountsSynchronizer.activeAccountUriFlow .mapNotNull { it?.takeIf { it.isNotEmpty() } } .collect { lastActiveAccountUri -> val accounts = uiState.value.accountList val selectedAccount = accounts.firstOrNull { it.uri.toString() == lastActiveAccountUri } if (selectedAccount != null && selectedAccount.uri != uiState.value.selectedAccount?.uri) { _uiState.update { it.copy(selectedAccount = selectedAccount) } } } } } fun onAccountSelected(account: LoggedAccount) { if (account.uri == uiState.value.selectedAccount?.uri) return launchInViewModel { _uiState.update { it.copy(selectedAccount = account) } activeAccountsSynchronizer.onAccountSelected(account.uri.toString()) } } } ================================================ FILE: feature/notifications/src/commonMain/kotlin/com/zhangke/fread/feature/message/screens/home/NotificationsTab.kt ================================================ package com.zhangke.fread.feature.message.screens.home import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.zhangke.framework.nav.BaseTab import com.zhangke.framework.nav.TabOptions import com.zhangke.fread.feature.notifications.Res import com.zhangke.fread.feature.notifications.ic_notification_tab import org.jetbrains.compose.resources.painterResource class NotificationsTab : BaseTab() { override val options: TabOptions @Composable get() { val icon = painterResource(Res.drawable.ic_notification_tab) return remember { TabOptions( title = "Notifications", icon = icon, ) } } @Composable override fun Content() { NotificationScreen() } } ================================================ FILE: feature/notifications/src/commonMain/kotlin/com/zhangke/fread/feature/message/screens/notification/NotificationContainerViewModel.kt ================================================ package com.zhangke.fread.feature.message.screens.notification import com.zhangke.framework.lifecycle.ContainerViewModel import com.zhangke.framework.lifecycle.ContainerViewModel.SubViewModelParams import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.feature.message.repo.notification.NotificationsRepo import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.account.LoggedAccount class NotificationContainerViewModel( private val statusProvider: StatusProvider, private val notificationsRepo: NotificationsRepo, private val statusUiStateAdapter: StatusUiStateAdapter, private val refactorToNewStatus: RefactorToNewStatusUseCase, private val statusUpdater: StatusUpdater, ) : ContainerViewModel() { fun getSubViewModel( account: LoggedAccount ): NotificationViewModel { return obtainSubViewModel( Params(account) ) } override fun createSubViewModel(params: Params): NotificationViewModel { return NotificationViewModel( statusProvider = statusProvider, account = params.account, notificationsRepo = notificationsRepo, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, statusUpdater = statusUpdater, ) } class Params( val account: LoggedAccount, ) : SubViewModelParams() { override val key: String get() = account.hashCode().toString() } } ================================================ FILE: feature/notifications/src/commonMain/kotlin/com/zhangke/fread/feature/message/screens/notification/NotificationTab.kt ================================================ package com.zhangke.fread.feature.message.screens.notification import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.statusBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MultiChoiceSegmentedButtonRow import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf 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.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.zhangke.framework.blur.applyBlurEffect import com.zhangke.framework.blur.blurEffectContainerColor import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.LocalContentPadding import com.zhangke.framework.composable.LocalSnackbarHostState import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.plusTopPadding import com.zhangke.framework.composable.textString import com.zhangke.framework.loadable.lazycolumn.LoadableInlineVideoLazyColumn import com.zhangke.framework.loadable.lazycolumn.rememberLoadableInlineVideoLazyColumnState import com.zhangke.framework.nav.BaseTab import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.TabOptions import com.zhangke.framework.utils.pxToDp import com.zhangke.fread.common.composable.ErrorContent import com.zhangke.fread.common.composable.ErrorType import com.zhangke.fread.commonbiz.shared.notification.StatusNotificationUi import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.ui.ComposedStatusInteraction import com.zhangke.fread.status.ui.StatusListPlaceholder import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel internal val LocalNotificationTabConsumeStatusBarInsets = compositionLocalOf { false } class NotificationTab( private val loggedAccount: LoggedAccount, ) : BaseTab() { override val options: TabOptions? @Composable get() = null @Composable override fun Content() { super.Content() val backstack = LocalNavBackStack.currentOrThrow val viewModel = koinViewModel().getSubViewModel(loggedAccount) val uiState by viewModel.uiState.collectAsState() val snackBarHostState = LocalSnackbarHostState.current TabPageContent( uiState = uiState, onSwitchTab = viewModel::onSwitchTab, onRefresh = viewModel::onRefresh, onLoadMore = viewModel::onLoadMore, composedStatusInteraction = viewModel.composedStatusInteraction, onAcceptClick = viewModel::onAcceptClick, onRejectClick = viewModel::onRejectClick, onNotificationShown = viewModel::onNotificationShown, onUnblockClick = viewModel::onUnblockClick, onCancelFollowRequestClick = viewModel::onCancelFollowRequestClick, ) ConsumeSnackbarFlow(snackBarHostState, viewModel.errorMessageFlow) ConsumeFlow(viewModel.openScreenFlow) { backstack.add(it) } if (uiState.dataList.isNotEmpty()) { val first = uiState.dataList.first() LaunchedEffect(first.id, first.fromLocal) { // 停留1秒表示已读 delay(1000) viewModel.onPageResume() } } } @Composable private fun TabPageContent( uiState: NotificationUiState, composedStatusInteraction: ComposedStatusInteraction, onSwitchTab: (Boolean) -> Unit, onRefresh: () -> Unit, onLoadMore: () -> Unit, onUnblockClick: (PlatformLocator, BlogAuthor) -> Unit, onRejectClick: (BlogAuthor) -> Unit, onAcceptClick: (BlogAuthor) -> Unit, onNotificationShown: (StatusNotificationUiState) -> Unit, onCancelFollowRequestClick: (PlatformLocator, BlogAuthor) -> Unit, ) { var tabTitleHeight: Dp by remember { mutableStateOf(20.dp) } val consumeStatusBarInsets = LocalNotificationTabConsumeStatusBarInsets.current Box( modifier = Modifier.fillMaxSize(), ) { CompositionLocalProvider( LocalContentPadding provides plusTopPadding(tabTitleHeight) ) { if (uiState.initializing) { StatusListPlaceholder() } else if (uiState.dataList.isEmpty()) { ErrorContent( modifier = Modifier.fillMaxSize(), type = ErrorType.Network, errorMessage = uiState.errorMessage?.let { textString(it) }, onRetryClick = onRefresh, ) } else { val state = rememberLoadableInlineVideoLazyColumnState( onRefresh = onRefresh, onLoadMore = onLoadMore, ) LoadableInlineVideoLazyColumn( state = state, modifier = Modifier.fillMaxSize(), refreshing = uiState.refreshing, loadState = uiState.loadMoreState, ) { itemsIndexed( items = uiState.dataList, ) { index, notification -> val backgroundColor by animateColorAsState( if (notification.unreadState) { MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2F) } else { Color.Transparent } ) StatusNotificationUi( modifier = Modifier.fillMaxWidth() .background(backgroundColor), notification = notification.notification, composedStatusInteraction = composedStatusInteraction, indexInList = index, onAcceptClick = onAcceptClick, onRejectClick = onRejectClick, onUnblockClick = onUnblockClick, onCancelFollowRequestClick = onCancelFollowRequestClick, ) if (notification.unreadState) { LaunchedEffect(notification) { delay(1000) onNotificationShown(notification) } } } } } } Column(modifier = Modifier.fillMaxWidth()) { Spacer( modifier = Modifier.fillMaxWidth() .height(LocalContentPadding.current.calculateTopPadding()) ) NotificationTabTitle( uiState = uiState, consumeStatusBarInsets = consumeStatusBarInsets, onTabCheckedChange = onSwitchTab, onHeightChanged = { tabTitleHeight = it }, ) } } } @Composable private fun NotificationTabTitle( uiState: NotificationUiState, consumeStatusBarInsets: Boolean, onTabCheckedChange: (inMentionsTab: Boolean) -> Unit, onHeightChanged: (Dp) -> Unit, ) { val containerColor = MaterialTheme.colorScheme.surface val density = LocalDensity.current Box( modifier = Modifier.fillMaxWidth() .onSizeChanged { onHeightChanged(it.height.pxToDp(density)) } .background(blurEffectContainerColor(containerColor = containerColor)) .applyBlurEffect(containerColor = containerColor), contentAlignment = Alignment.Center, ) { MultiChoiceSegmentedButtonRow( modifier = Modifier.fillMaxWidth(0.7F) .then( if (consumeStatusBarInsets) { Modifier.windowInsetsPadding(WindowInsets.statusBars) } else { Modifier } ), ) { SegmentedButton( checked = !uiState.inOnlyMentionTab, onCheckedChange = { onTabCheckedChange(false) }, shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), ) { Text(text = stringResource(LocalizedString.notificationsTabAll)) } SegmentedButton( checked = uiState.inOnlyMentionTab, onCheckedChange = { onTabCheckedChange(true) }, shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), ) { Text(text = stringResource(LocalizedString.notificationsTabMention)) } } } } } ================================================ FILE: feature/notifications/src/commonMain/kotlin/com/zhangke/fread/feature/message/screens/notification/NotificationUiState.kt ================================================ package com.zhangke.fread.feature.message.screens.notification import com.zhangke.framework.composable.TextString import com.zhangke.framework.controller.LoadableUiState import com.zhangke.framework.utils.LoadState import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.notification.StatusNotification data class NotificationUiState( val account: LoggedAccount, val inOnlyMentionTab: Boolean, override val initializing: Boolean, override val dataList: List, override val refreshing: Boolean, override val loadMoreState: LoadState, override val errorMessage: TextString?, ) : LoadableUiState { override fun copyObject( dataList: List, initializing: Boolean, refreshing: Boolean, loadMoreState: LoadState, errorMessage: TextString? ): NotificationUiState { return copy( dataList = dataList, initializing = initializing, refreshing = refreshing, loadMoreState = loadMoreState, errorMessage = errorMessage, ) } companion object { fun default( account: LoggedAccount, ): NotificationUiState { return NotificationUiState( account = account, inOnlyMentionTab = false, initializing = false, dataList = emptyList(), refreshing = false, loadMoreState = LoadState.Idle, errorMessage = null, ) } } } data class StatusNotificationUiState( val notification: StatusNotification, val unreadState: Boolean = notification.unread, val fromLocal: Boolean, ) { val id: String get() = notification.id fun updateStatus(status: StatusUiState): StatusNotificationUiState? { if (notification.status?.status?.id != status.status.id) return null return when (notification) { is StatusNotification.Mention -> copy(notification = notification.copy(status = status)) is StatusNotification.Reply -> copy(notification = notification.copy(reply = status)) is StatusNotification.Quote -> copy(notification = notification.copy(quote = status)) else -> null } } } ================================================ FILE: feature/notifications/src/commonMain/kotlin/com/zhangke/fread/feature/message/screens/notification/NotificationViewModel.kt ================================================ package com.zhangke.fread.feature.message.screens.notification import com.zhangke.framework.collections.getOrNull import com.zhangke.framework.collections.removeFirstOrNull import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.controller.LoadableController import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.lifecycle.SubViewModel import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.feeds.IInteractiveHandler import com.zhangke.fread.commonbiz.shared.feeds.InteractiveHandleResult import com.zhangke.fread.commonbiz.shared.feeds.InteractiveHandler import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.feature.message.repo.notification.NotificationsRepo import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.Relationships import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.notification.INotificationResolver import com.zhangke.fread.status.notification.StatusNotification import com.zhangke.fread.status.uri.FormalUri import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class NotificationViewModel( private val statusProvider: StatusProvider, private val account: LoggedAccount, private val statusUiStateAdapter: StatusUiStateAdapter, private val refactorToNewStatus: RefactorToNewStatusUseCase, private val notificationsRepo: NotificationsRepo, statusUpdater: StatusUpdater, ) : SubViewModel(), IInteractiveHandler by InteractiveHandler( statusProvider = statusProvider, statusUpdater = statusUpdater, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, ) { private val loadableController = LoadableController( coroutineScope = viewModelScope, initialUiState = NotificationUiState.default(account = account), onPostSnackMessage = { launchInViewModel { mutableErrorMessageFlow.emit(it) } } ) private val _uiState = loadableController.mutableUiState val uiState = loadableController.uiState private var reportedNotificationId: String? = null private var cursor: String? = null private var reachEnd: Boolean = false private val userDetailSnapshot = mutableSetOf() init { initInteractiveHandler( coroutineScope = viewModelScope, onInteractiveHandleResult = { interactiveResult -> when (interactiveResult) { is InteractiveHandleResult.UpdateStatus -> { updateStatus(interactiveResult.status) } is InteractiveHandleResult.DeleteStatus -> { } is InteractiveHandleResult.UpdateFollowState -> { // no-op updateUiStateRelationships( authorUri = interactiveResult.userUri, block = { relationships -> relationships?.copy( following = interactiveResult.following, ) } ) } } } ) loadableController.initData( getDataFromServer = ::getDataFromServer, getDataFromLocal = ::getDataFromLocal, ) } fun onSwitchTab(onlyMentions: Boolean) { _uiState.update { it.copy(inOnlyMentionTab = onlyMentions) } cursor = null reachEnd = false loadableController.initData( getDataFromServer = ::getDataFromServer, getDataFromLocal = ::getDataFromLocal, ) } fun onRefresh(hideRefreshing: Boolean = false) { loadableController.onRefresh(hideRefreshing) { getDataFromServer(null) } } fun onLoadMore() { if (reachEnd) return loadableController.onLoadMore { getDataFromServer(loadMore = true) } } fun onNotificationShown(notification: StatusNotificationUiState) { if (!notification.unreadState) return _uiState.update { state -> state.copy( dataList = state.dataList.map { if (it.id == notification.id) { it.copy(unreadState = false) } else { it } } ) } } fun onPageResume() { val firstNotificationId = uiState.value .dataList .firstOrNull() ?.takeIf { !it.fromLocal } ?.id ?: return if (firstNotificationId == reportedNotificationId) return reportedNotificationId = firstNotificationId launchInViewModel { statusProvider.notificationResolver .updateUnreadNotification( account = account, notificationLastReadId = firstNotificationId ) } } fun onRejectClick(author: BlogAuthor) { launchInViewModel { statusProvider.notificationResolver .rejectFollowRequest(account, author) .onSuccess { onRefresh(true) } .onFailure { mutableErrorMessageFlow.emitTextMessageFromThrowable(it) } } } fun onAcceptClick(author: BlogAuthor) { launchInViewModel { statusProvider.notificationResolver .acceptFollowRequest(account, author) .onSuccess { onRefresh(true) } .onFailure { mutableErrorMessageFlow.emitTextMessageFromThrowable(it) } } } fun onUnblockClick(locator: PlatformLocator, author: BlogAuthor) { launchInViewModel { statusProvider.accountManager.unblockAccount( account = account, user = author, ).onSuccess { updateUiStateRelationships( authorUri = author.uri, block = { relationships -> relationships?.copy(blocking = false) }, ) }.onFailure { mutableErrorMessageFlow.emitTextMessageFromThrowable(it) } } } fun onCancelFollowRequestClick(locator: PlatformLocator, author: BlogAuthor) { launchInViewModel { statusProvider.accountManager.cancelFollowRequest( account = account, user = author, ).onSuccess { updateUiStateRelationships( authorUri = author.uri, block = { relationships -> relationships?.copy(requested = false) }, ) }.onFailure { mutableErrorMessageFlow.emitTextMessageFromThrowable(it) } } } private fun updateUiStateRelationships( authorUri: FormalUri, block: (Relationships?) -> Relationships?, ) { userDetailSnapshot.removeFirstOrNull { it.uri == authorUri }?.let { user -> block(user.relationships)?.let { userDetailSnapshot.add(user.copy(relationships = it)) } } _uiState.update { state -> state.copy( dataList = updateAuthorRelationships( notifications = state.dataList, authorUri = authorUri, block = block, ) ) } } private fun updateAuthorRelationships( notifications: List, authorUri: FormalUri, block: (Relationships?) -> Relationships?, ): List { return notifications.map { notificationUiState -> val notification = notificationUiState.notification when (notification) { is StatusNotification.Follow -> { if (notification.author.uri == authorUri) { notificationUiState.copy( notification = notification.copy( author = notification.author.copy( relationships = block(notification.author.relationships) ) ) ) } else { notificationUiState } } is StatusNotification.FollowRequest -> { if (notification.author.uri == authorUri) { notificationUiState.copy( notification = notification.copy( author = notification.author.copy( relationships = block(notification.author.relationships) ) ) ) } else { notificationUiState } } else -> notificationUiState } } } private suspend fun getDataFromServer( cursor: String? = this.cursor, loadMore: Boolean = false, ): Result> { return statusProvider.notificationResolver.getNotifications( account = account, type = if (uiState.value.inOnlyMentionTab) { INotificationResolver.NotificationRequestType.MENTION } else { INotificationResolver.NotificationRequestType.ALL }, cursor = cursor, ).map { this.cursor = it.cursor this.reachEnd = it.reachEnd it.notifications .map { n -> StatusNotificationUiState(n, fromLocal = false) } .let { list -> fillUserDetails(list, userDetailSnapshot) } }.onSuccess { if (loadMore || uiState.value.inOnlyMentionTab) { notificationsRepo.insertNotification( account.uri, it.map { n -> n.notification }) } else { notificationsRepo.replaceNotifications( account.uri, it.map { n -> n.notification }) } launch { loadAdditionalData(account, it) } } } private suspend fun loadAdditionalData( account: LoggedAccount, notifications: List ) { // Just Follow/FollowRequest notifications need additional data currently. val users = notifications .mapNotNull { when (it.notification) { is StatusNotification.Follow -> it.notification.author is StatusNotification.FollowRequest -> it.notification.author else -> null } } .distinctBy { it.uri } if (users.isEmpty()) return statusProvider.notificationResolver .getNotificationUserDetail( account = account, users = users, ).onSuccess { users -> if (users.isNotEmpty()) { userDetailSnapshot.addAll(users) _uiState.update { state -> state.copy( dataList = fillUserDetails(state.dataList, userDetailSnapshot) ) } } } } private fun fillUserDetails( notifications: List, users: Collection, ): List { if (users.isEmpty() || notifications.isEmpty()) return notifications return notifications.map { notificationUiState -> val notification = notificationUiState.notification when (notification) { is StatusNotification.Follow -> { val user = users.getOrNull { it.uri == notification.author.uri } if (user != null) { notificationUiState.copy(notification = notification.copy(author = user)) } else { notificationUiState } } is StatusNotification.FollowRequest -> { val user = users.getOrNull { it.uri == notification.author.uri } if (user != null) { notificationUiState.copy(notification = notification.copy(author = user)) } else { notificationUiState } } else -> notificationUiState } } } private suspend fun getDataFromLocal(): List { return notificationsRepo.getNotifications(account.uri) .filter { if (uiState.value.inOnlyMentionTab) { it is StatusNotification.Mention || it is StatusNotification.Quote || it is StatusNotification.Reply } else { true } }.sortedByDescending { it.createAt.epochMillis } .map { StatusNotificationUiState(it, fromLocal = true) } } private suspend fun updateStatus(newStatus: StatusUiState) { var updatedNotification: StatusNotification? = null _uiState.update { current -> current.copy( dataList = current.dataList.map { notification -> notification.updateStatus(newStatus)?.let { updatedNotification = it.notification it } ?: notification } ) } if (updatedNotification != null) { notificationsRepo.updateNotification( accountUri = account.uri, notification = updatedNotification, ) } } } ================================================ FILE: feature/notifications/src/iosMain/kotlin/com/zhangke/fread/feature/message/di/NotificationsIosModule.kt ================================================ package com.zhangke.fread.feature.message.di import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.zhangke.fread.common.documentDirectory import com.zhangke.fread.feature.message.repo.notification.NotificationsDatabase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import org.koin.core.module.Module actual fun Module.createPlatformModule() { factory { val dbFilePath = getDBFilePath(NotificationsDatabase.DB_NAME) Room.databaseBuilder( name = dbFilePath, ).setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() } } private fun getDBFilePath(dbName: String): String { return documentDirectory() + "/$dbName" } ================================================ FILE: feature/profile/.gitignore ================================================ /build ================================================ FILE: feature/profile/build.gradle.kts ================================================ plugins { id("fread.project.feature.kmp") id("com.google.devtools.ksp") } android { namespace = "com.zhangke.fread.profile" } kotlin { sourceSets { commonMain { dependencies { implementation(project(":framework")) implementation(project(":bizframework:status-provider")) implementation(project(":commonbiz:common")) implementation(project(":commonbiz:analytics")) implementation(project(":commonbiz:sharedscreen")) implementation(project(":commonbiz:status-ui")) implementation(compose.components.resources) implementation(libs.arrow.core) implementation(libs.jetbrains.lifecycle.viewmodel) implementation(libs.androidx.paging.common) implementation(libs.auto.service.annotations) implementation(libs.krouter.runtime) } } commonTest { dependencies { implementation(kotlin("test")) } } androidMain { dependencies { implementation(libs.androidx.core.ktx) implementation(libs.bundles.androidx.activity) implementation(libs.androidx.browser) } } } configureCommonMainKsp() } dependencies { kspAll(libs.auto.service.ksp) kspAll(libs.krouter.collecting.compiler) } compose { resources { publicResClass = false packageOfResClass = "com.zhangke.fread.feature.profile" generateResClass = always } } ================================================ FILE: feature/profile/consumer-rules.pro ================================================ ================================================ FILE: feature/profile/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: feature/profile/src/commonMain/composeResources/drawable/ic_code.xml ================================================ ================================================ FILE: feature/profile/src/commonMain/composeResources/drawable/ic_github_logo.xml ================================================ ================================================ FILE: feature/profile/src/commonMain/composeResources/drawable/ic_profile_tab.xml ================================================ ================================================ FILE: feature/profile/src/commonMain/composeResources/drawable/ic_ratting.xml ================================================ ================================================ FILE: feature/profile/src/commonMain/composeResources/drawable/ic_telegram.xml ================================================ ================================================ FILE: feature/profile/src/commonMain/composeResources/drawable/kofi_symbol.xml ================================================ ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/ProfileNavEntryProvider.kt ================================================ package com.zhangke.fread.profile import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.zhangke.framework.nav.NavEntryProvider import com.zhangke.framework.nav.dialogMetadata import com.zhangke.fread.profile.screen.donate.DonateScreen import com.zhangke.fread.profile.screen.donate.DonateScreenNavKey import com.zhangke.fread.profile.screen.opensource.OpenSourceScreen import com.zhangke.fread.profile.screen.opensource.OpenSourceScreenNavKey import com.zhangke.fread.profile.screen.setting.SettingScreen import com.zhangke.fread.profile.screen.setting.SettingScreenNavKey import com.zhangke.fread.profile.screen.setting.about.AboutScreen import com.zhangke.fread.profile.screen.setting.about.AboutScreenNavKey import com.zhangke.fread.profile.screen.setting.appearance.AppearanceSettingsNavKey import com.zhangke.fread.profile.screen.setting.appearance.AppearanceSettingsScreen import com.zhangke.fread.profile.screen.setting.behavior.BehaviorSettingsNavKey import com.zhangke.fread.profile.screen.setting.behavior.BehaviorSettingsScreen import kotlinx.serialization.modules.PolymorphicModuleBuilder import kotlinx.serialization.modules.subclass import org.koin.compose.viewmodel.koinViewModel class ProfileNavEntryProvider : NavEntryProvider { override fun EntryProviderScope.build() { entry { SettingScreen(koinViewModel()) } entry { AboutScreen(koinViewModel()) } entry { AppearanceSettingsScreen(koinViewModel()) } entry { BehaviorSettingsScreen(koinViewModel()) } entry { OpenSourceScreen() } entry( metadata = dialogMetadata(), ) { DonateScreen() } } override fun PolymorphicModuleBuilder.polymorph() { subclass(SettingScreenNavKey::class) subclass(AboutScreenNavKey::class) subclass(AppearanceSettingsNavKey::class) subclass(BehaviorSettingsNavKey::class) subclass(OpenSourceScreenNavKey::class) subclass(DonateScreenNavKey::class) } } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/ProfileScreenVisitor.kt ================================================ package com.zhangke.fread.profile import androidx.navigation3.runtime.NavKey import com.zhangke.fread.commonbiz.shared.IProfileScreenVisitor import com.zhangke.fread.profile.screen.donate.DonateScreenNavKey class ProfileScreenVisitor : IProfileScreenVisitor { override fun getDonateScreen(): NavKey { return DonateScreenNavKey } } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/di/ProfileModule.kt ================================================ package com.zhangke.fread.profile.di import com.zhangke.framework.nav.NavEntryProvider import com.zhangke.fread.commonbiz.shared.IProfileScreenVisitor import com.zhangke.fread.profile.ProfileNavEntryProvider import com.zhangke.fread.profile.ProfileScreenVisitor import com.zhangke.fread.profile.screen.home.ProfileHomeViewModel import com.zhangke.fread.profile.screen.setting.SettingScreenModel import com.zhangke.fread.profile.screen.setting.about.AboutViewModel import com.zhangke.fread.profile.screen.setting.appearance.AppearanceSettingsViewModel import com.zhangke.fread.profile.screen.setting.behavior.BehaviorSettingsViewModel import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.bind import org.koin.dsl.module val profileModule = module { factoryOf(::ProfileNavEntryProvider) bind NavEntryProvider::class viewModelOf(::ProfileHomeViewModel) viewModelOf(::SettingScreenModel) viewModelOf(::AboutViewModel) viewModelOf(::AppearanceSettingsViewModel) viewModelOf(::BehaviorSettingsViewModel) singleOf(::ProfileScreenVisitor) bind IProfileScreenVisitor::class } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/donate/DonateScreen.kt ================================================ package com.zhangke.fread.profile.screen.donate import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth 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.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.noRippleClick import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.common.browser.launchWebTabInApp import com.zhangke.fread.common.config.AppCommonConfig import com.zhangke.fread.feature.profile.Res import com.zhangke.fread.feature.profile.af_dian import com.zhangke.fread.feature.profile.kofi_symbol import com.zhangke.fread.localization.LocalizedString import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @Serializable object DonateScreenNavKey : NavKey @Composable fun DonateScreen() { val browserLauncher = LocalActivityBrowserLauncher.current val coroutineScope = rememberCoroutineScope() val backStack = LocalNavBackStack.currentOrThrow DonateContent( onDonateClick = { browserLauncher.launchWebTabInApp( scope = coroutineScope, url = it.url, checkAppSupportPage = false, ) backStack.removeLastOrNull() }, ) } @Composable private fun DonateContent( onDonateClick: (DonateItem) -> Unit, ) { Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), ) { Column( modifier = Modifier.fillMaxWidth() .padding(horizontal = 16.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( text = stringResource(LocalizedString.profileDonatePageTitle), fontSize = 18.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) val donateList = remember { buildDonateList() } for (donateItem in donateList) { Row( modifier = Modifier.fillMaxWidth() .noRippleClick { onDonateClick(donateItem) }, verticalAlignment = Alignment.CenterVertically, ) { Image( modifier = Modifier.size(24.dp) .clip(CircleShape), painter = donateItem.logo(), contentDescription = null, ) Column( modifier = Modifier.weight(1F).padding(start = 16.dp), horizontalAlignment = Alignment.Start, ) { Text( text = donateItem.title, style = MaterialTheme.typography.bodyMedium .copy(fontWeight = FontWeight.SemiBold), ) Text( modifier = Modifier, text = donateItem.url, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } } } private fun buildDonateList(): List { return listOf( DonateItem.kofi(), DonateItem.afDian(), ) } data class DonateItem( val title: String, val url: String, val logo: @Composable () -> Painter, ) { companion object { fun kofi(): DonateItem { return DonateItem( title = "Ko-fi", url = AppCommonConfig.DONATE_KO_FI_LINK, logo = { painterResource(Res.drawable.kofi_symbol) }, ) } fun afDian(): DonateItem { return DonateItem( title = "AFDIAN(爱发电)", url = AppCommonConfig.DONATE_AF_DIAN_LINK, logo = { painterResource(Res.drawable.af_dian) }, ) } } } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/home/ProfileHomeUiState.kt ================================================ package com.zhangke.fread.profile.screen.home import com.zhangke.fread.status.model.LoggedAccountDetail data class ProfileHomeUiState( val accountDataList: List, ) data class ProfileAccountUiState( val account: LoggedAccountDetail, val authFailed: Boolean, val active: Boolean, ) ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/home/ProfileHomeViewModel.kt ================================================ package com.zhangke.fread.profile.screen.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation3.runtime.NavKey import com.zhangke.framework.collections.container import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.common.account.ActiveAccountsSynchronizer import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.account.AccountRefreshResult import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.account.isAuthenticationFailure import com.zhangke.fread.status.model.PlatformLocator import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class ProfileHomeViewModel( private val statusProvider: StatusProvider, private val activeAccountsSynchronizer: ActiveAccountsSynchronizer, ) : ViewModel() { private val _uiState = MutableStateFlow(ProfileHomeUiState(emptyList())) val uiState: StateFlow get() = _uiState.asStateFlow() private val _openPageFlow = MutableSharedFlow() val openPageFlow = _openPageFlow.asSharedFlow() private var refreshAccountJob: Job? = null init { observeAccountFlow() launchInViewModel { activeAccountsSynchronizer.activeAccountUriFlow .mapNotNull { it?.takeIf { it.isNotEmpty() } } .collect { lastActiveAccountUri -> _uiState.update { state -> state.copy( accountDataList = state.accountDataList.map { account -> account.copy( active = account.account.account.uri.toString() == lastActiveAccountUri, ) } ) } } } } private fun observeAccountFlow() { viewModelScope.launch { statusProvider.accountManager .getAllAccountDetailFlow() .map { list -> val activeAccountUri = activeAccountsSynchronizer.activeAccountUriFlow.value val dirtyAccountList = uiState.value.accountDataList list.map { account -> val authFailed = dirtyAccountList.firstOrNull { it.account.account.uri == account.account.uri }?.authFailed ?: false ProfileAccountUiState( account = account, authFailed = authFailed, active = account.account.uri.toString() == activeAccountUri, ) } } .collect { list -> _uiState.update { it.copy(accountDataList = list) } } } } fun refreshAccountInfo() { if (refreshAccountJob?.isActive == true) return refreshAccountJob = launchInViewModel { val refreshedList = statusProvider.accountManager.refreshAllAccountInfo() val authFailedAccounts = refreshedList.filter { it is AccountRefreshResult.Failure && it.error.isAuthenticationFailure } _uiState.update { state -> state.copy( accountDataList = state.accountDataList .map { account -> val authFailed = authFailedAccounts.container { it.account.uri == account.account.account.uri } account.copy(authFailed = authFailed) } ) } } } fun onAccountClick(account: LoggedAccount) { launchInViewModel { statusProvider.screenProvider .getUserDetailScreen( locator = account.platformLocator, uri = account.uri, userId = account.id, )?.let { _openPageFlow.emit(it) } } } fun onLoginClick(account: LoggedAccount) { launchInViewModel { statusProvider.accountManager.triggerAuthBySource(account.platform, account) } } private val LoggedAccount.platformLocator: PlatformLocator get() = PlatformLocator( baseUrl = platform.baseUrl, accountUri = uri, ) } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/home/ProfileScreen.kt ================================================ package com.zhangke.fread.profile.screen.home import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.zhangke.framework.blur.applyBlurEffect import com.zhangke.framework.blur.applyBlurSource import com.zhangke.framework.blur.blurEffectContainerColor import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.LocalContentPadding import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.SingleRowTopAppBar import com.zhangke.framework.composable.TopAppBarColors import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.plus import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.common.composable.EmptyContent import com.zhangke.fread.common.composable.EmptyContentType import com.zhangke.fread.commonbiz.shared.LocalModuleScreenVisitor import com.zhangke.fread.commonbiz.shared.composable.UserInfoCard import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.profile.screen.setting.SettingScreenNavKey import com.zhangke.fread.status.account.LoggedAccount import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @Composable fun ProfileScreen() { val backStack = LocalNavBackStack.currentOrThrow val viewModel = koinViewModel() val uiState by viewModel.uiState.collectAsState() val moduleScreenVisitor = LocalModuleScreenVisitor.current LaunchedEffect(Unit) { viewModel.refreshAccountInfo() } ProfileHomePageContent( uiState = uiState, onAddAccountClick = { backStack.add(moduleScreenVisitor.feedsScreenVisitor.getAddContentScreen()) }, onSettingClick = { backStack.add(SettingScreenNavKey) }, onAccountClick = { viewModel.onAccountClick(it) }, onLoginClick = viewModel::onLoginClick, ) ConsumeFlow(viewModel.openPageFlow) { backStack.add(it) } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ProfileHomePageContent( uiState: ProfileHomeUiState, onAddAccountClick: () -> Unit, onSettingClick: () -> Unit, onAccountClick: (LoggedAccount) -> Unit, onLoginClick: (LoggedAccount) -> Unit, ) { val surfaceColor = MaterialTheme.colorScheme.surface Scaffold( modifier = Modifier.fillMaxSize(), topBar = { SingleRowTopAppBar( modifier = Modifier.applyBlurEffect(containerColor = surfaceColor), colors = TopAppBarColors.default( containerColor = blurEffectContainerColor(containerColor = surfaceColor), ), height = 48.dp, title = { Text( modifier = Modifier, text = stringResource(LocalizedString.profilePageTitle), color = MaterialTheme.colorScheme.onSurface, ) }, actions = { SimpleIconButton( onClick = onAddAccountClick, imageVector = Icons.Default.PersonAdd, contentDescription = "Add Account", ) SimpleIconButton( onClick = onSettingClick, imageVector = Icons.Default.Settings, contentDescription = "Settings", ) }, ) }, ) { innerPadding -> Box(modifier = Modifier.fillMaxSize()) { if (uiState.accountDataList.isEmpty()) { EmptyContent( modifier = Modifier.fillMaxSize(), type = EmptyContentType.Account, onClick = onAddAccountClick, ) } else { LazyColumn( modifier = Modifier.fillMaxWidth().applyBlurSource(), contentPadding = LocalContentPadding.current.plus(innerPadding), ) { items(uiState.accountDataList) { item -> AccountDetail( modifier = Modifier.fillMaxWidth(), accountDetail = item, onAccountClick = onAccountClick, onLoginClick = onLoginClick, ) Spacer(modifier = Modifier.height(16.dp)) } } } } } } @Composable private fun AccountDetail( modifier: Modifier, accountDetail: ProfileAccountUiState, onAccountClick: (LoggedAccount) -> Unit, onLoginClick: (LoggedAccount) -> Unit, ) { UserInfoCard( modifier = modifier.padding(horizontal = 16.dp), user = accountDetail.account.author, showActiveState = accountDetail.active, actionButton = if (accountDetail.authFailed) { { TextButton( modifier = Modifier, onClick = { onLoginClick(accountDetail.account.account) }, colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.error, ), ) { Text(text = stringResource(LocalizedString.profileAccountNotLogin)) } } } else { null }, onUserClick = { onAccountClick(accountDetail.account.account) }, ) } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/home/ProfileTab.kt ================================================ package com.zhangke.fread.profile.screen.home import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.zhangke.framework.nav.Tab import com.zhangke.framework.nav.TabOptions import com.zhangke.fread.feature.profile.Res import com.zhangke.fread.feature.profile.ic_profile_tab import org.jetbrains.compose.resources.painterResource class ProfileTab : Tab { override val options: TabOptions @Composable get() { val icon = painterResource(Res.drawable.ic_profile_tab) return remember { TabOptions( title = "Profile", icon = icon, ) } } @Composable override fun Content() { ProfileScreen() } } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/opensource/OpenSourceScreen.kt ================================================ package com.zhangke.fread.profile.screen.opensource import androidx.compose.foundation.clickable 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.common.browser.launchWebTabInApp import com.zhangke.fread.localization.LocalizedString import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable object OpenSourceScreenNavKey : NavKey @Composable fun OpenSourceScreen() { val backStack = LocalNavBackStack.currentOrThrow val browserLauncher = LocalActivityBrowserLauncher.current val coroutineScope = rememberCoroutineScope() Scaffold( topBar = { Toolbar( title = stringResource(LocalizedString.profileSettingOpenSourceTitle), onBackClick = backStack::removeLastOrNull, ) } ) { innerPadding -> Box( modifier = Modifier .fillMaxSize() .padding(innerPadding), ) { val openSourceInfoList = remember { buildOpenSourceInfoList() } LazyColumn( modifier = Modifier.fillMaxSize(), ) { items(openSourceInfoList) { openSourceInfo -> OpenSourceItem( openSource = openSourceInfo, onClick = { browserLauncher.launchWebTabInApp(coroutineScope, it.url) }, ) } } } } } @Composable private fun OpenSourceItem( openSource: OpenSourceInfo, onClick: (OpenSourceInfo) -> Unit, ) { Column( modifier = Modifier .clickable { onClick(openSource) } .fillMaxWidth() .padding(top = 8.dp), ) { Text( modifier = Modifier.padding(start = 16.dp, end = 16.dp), text = "${openSource.name} - ${openSource.author}", style = MaterialTheme.typography.titleMedium, ) Text( modifier = Modifier.padding(start = 16.dp, top = 4.dp, end = 16.dp), text = openSource.license, style = MaterialTheme.typography.bodyMedium, ) Text( modifier = Modifier.padding(start = 16.dp, top = 4.dp, end = 16.dp), text = openSource.url, maxLines = 1, style = MaterialTheme.typography.bodyMedium, ) HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) } } private fun buildOpenSourceInfoList(): List { return listOf( OpenSourceInfo( name = "Fread", author = "ZhangKe", license = OpenSourceInfo.LICENSE_APACHE_2, url = "https://github.com/0xZhangKe/Fread" ), OpenSourceInfo( name = "KRouter", author = "ZhangKe", license = OpenSourceInfo.LICENSE_APACHE_2, url = "https://github.com/0xZhangKe/KRouter" ), OpenSourceInfo( name = "Filt", author = "ZhangKe", license = OpenSourceInfo.LICENSE_APACHE_2, url = "https://github.com/0xZhangKe/Filt" ), OpenSourceInfo( name = "ActivityPub-Kotlin", author = "ZhangKe", license = OpenSourceInfo.LICENSE_APACHE_2, url = "https://github.com/0xZhangKe/ActivityPub-Kotlin" ), OpenSourceInfo( name = "Kotlin", author = "Jetbrains", license = OpenSourceInfo.LICENSE_APACHE_2, url = "https://kotlinlang.org/" ), OpenSourceInfo( name = "Jetpack Compose", author = "Google", license = OpenSourceInfo.LICENSE_APACHE_2, url = "https://developer.android.com/jetpack/androidx/releases/compose" ), OpenSourceInfo( name = "Jetpack", author = "Google", license = OpenSourceInfo.LICENSE_APACHE_2, url = "https://developer.android.com/jetpack" ), OpenSourceInfo( name = "AndroidX", author = "Google", license = OpenSourceInfo.LICENSE_APACHE_2, url = "https://developer.android.com/jetpack/androidx/" ), OpenSourceInfo( name = "Gson", author = "Google", license = OpenSourceInfo.LICENSE_APACHE_2, url = "https://github.com/google/gson/" ), OpenSourceInfo( name = "OkHttp", author = "Square", license = OpenSourceInfo.LICENSE_APACHE_2, url = "https://square.github.io/okhttp/" ), OpenSourceInfo( name = "Retrofit", author = "Square", license = OpenSourceInfo.LICENSE_APACHE_2, url = "https://github.com/square/retrofit" ), OpenSourceInfo( name = "Accompanist", author = "Google", license = OpenSourceInfo.LICENSE_APACHE_2, url = "https://github.com/google/accompanist" ), OpenSourceInfo( name = "Material-Components", author = "Google", license = OpenSourceInfo.LICENSE_APACHE_2, url = "https://github.com/material-components/material-components-android" ), OpenSourceInfo( name = "compose-richtext", author = "halilozercan", license = OpenSourceInfo.LICENSE_APACHE_2, url = "https://github.com/halilozercan/compose-richtext", ), OpenSourceInfo( name = "ComposeReorderable", author = "André Claßen", license = OpenSourceInfo.LICENSE_APACHE_2, url = "https://github.com/aclassen/ComposeReorderable", ), OpenSourceInfo( name = "compose-wheel-picker", author = "zj565061763", license = "MIT license", url = "https://github.com/zj565061763/compose-wheel-picker", ), ) } data class OpenSourceInfo( val name: String, val author: String, val license: String, val url: String, ) { companion object { const val LICENSE_APACHE_2 = "The Apache Software License, Version 2.0" } } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/setting/FeedbackBottomSheet.kt ================================================ package com.zhangke.fread.profile.screen.setting import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.filled.Email import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.common.browser.launchWebTabInApp import com.zhangke.fread.common.config.AppCommonConfig import com.zhangke.fread.common.handler.LocalTextHandler import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.feature.profile.Res import com.zhangke.fread.feature.profile.ic_github_logo import com.zhangke.fread.feature.profile.ic_telegram import com.zhangke.framework.composable.rememberTransientModalBottomSheetState import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource @OptIn(ExperimentalMaterial3Api::class) @Composable fun FeedbackBottomSheet( onDismissRequest: () -> Unit, ) { val textHandler = LocalTextHandler.current val browserLauncher = LocalActivityBrowserLauncher.current val sheetState = rememberTransientModalBottomSheetState() val coroutineScope = rememberCoroutineScope() ModalBottomSheet( sheetState = sheetState, onDismissRequest = { coroutineScope.launch { sheetState.hide() onDismissRequest() } }, ) { Column { Row( modifier = Modifier .clickable { onDismissRequest() browserLauncher.launchWebTabInApp(coroutineScope, AppCommonConfig.TELEGRAM_GROUP) } .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Image( modifier = Modifier .size(24.dp) .clip(CircleShape), imageVector = vectorResource(Res.drawable.ic_telegram), contentDescription = null, ) Spacer(modifier = Modifier.width(16.dp)) Text( text = stringResource(LocalizedString.profileSettingOpenSourceFeedbackTelegram), style = MaterialTheme.typography.titleMedium, ) } Row( modifier = Modifier .clickable { onDismissRequest() browserLauncher.launchWebTabInApp(coroutineScope, AppCommonConfig.FEEDBACK_URL) } .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( modifier = Modifier.size(24.dp), imageVector = vectorResource(Res.drawable.ic_github_logo), contentDescription = null, ) Spacer(modifier = Modifier.width(16.dp)) Text( text = stringResource(LocalizedString.profileSettingOpenSourceFeedbackGithub), style = MaterialTheme.typography.titleMedium, ) } Row( modifier = Modifier .clickable { onDismissRequest() textHandler.openSendEmail() } .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( modifier = Modifier.size(24.dp), imageVector = Icons.Default.Email, contentDescription = null, ) Spacer(modifier = Modifier.width(16.dp)) Text( text = stringResource(LocalizedString.profileSettingOpenSourceFeedbackEmail), style = MaterialTheme.typography.titleMedium, ) } } } } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/setting/SettingComponents.kt ================================================ package com.zhangke.fread.profile.screen.setting import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.fillMaxWidth import androidx.compose.foundation.layout.heightIn 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.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.graphics.vector.ImageVector import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.PopupMenu import kotlinx.coroutines.delay import kotlinx.coroutines.launch private val itemHeight = 82.dp @Composable internal fun SettingItemWithSwitch( icon: ImageVector, title: String, subtitle: String, checked: Boolean, onCheckedChangeRequest: (Boolean) -> Unit, ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Box(modifier = Modifier.weight(1F)) { SettingItem( icon = icon, title = title, subtitle = subtitle, onClick = {}, ) } Switch( modifier = Modifier, checked = checked, onCheckedChange = onCheckedChangeRequest, ) Spacer(modifier = Modifier.width(16.dp)) } } @Composable internal fun SettingItemWithPopup( icon: ImageVector, title: String, subtitle: String, dropDownItemCount: Int, dropDownItemText: @Composable (Int) -> String, onItemClick: (Int) -> Unit, ) { val coroutineScope = rememberCoroutineScope() Box(modifier = Modifier.fillMaxWidth()) { var showPopup by remember { mutableStateOf(false) } SettingItem( icon = icon, title = title, subtitle = subtitle, onClick = { showPopup = true }, ) PopupMenu( expanded = showPopup, offset = DpOffset(x = 36.dp, y = 0.dp), onDismissRequest = { showPopup = false }, ) { repeat(dropDownItemCount) { index -> DropdownMenuItem( text = { Text(dropDownItemText(index)) }, onClick = { showPopup = false coroutineScope.launch { delay(100) onItemClick(index) } }, ) } } } } @Composable internal fun SettingItem( icon: ImageVector, title: String, subtitle: String?, redDot: Boolean = false, onClick: () -> Unit, ) { SettingItem( icon = { Icon( modifier = Modifier.size(24.dp), imageVector = icon, contentDescription = title, ) }, title = title, redDot = redDot, subtitle = subtitle, onClick = onClick, ) } @Composable internal fun SettingItem( icon: @Composable () -> Unit, title: String, subtitle: String?, redDot: Boolean = false, onClick: () -> Unit, ) { Box( modifier = Modifier .fillMaxWidth() .clickable { onClick() }, ) { Row( modifier = Modifier .heightIn(min = itemHeight) .padding(16.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { icon() Spacer(modifier = Modifier.width(16.dp)) Column { Row( verticalAlignment = Alignment.CenterVertically, ) { Text( text = title, style = MaterialTheme.typography.titleMedium, maxLines = 1, ) if (redDot) { Spacer(modifier = Modifier.width(4.dp)) Box( modifier = Modifier.size(4.dp) .clip(CircleShape) .background(Color.Red.copy(alpha = 0.8F)), ) } } if (!subtitle.isNullOrBlank()) { Text( modifier = Modifier.padding(top = 4.dp), text = subtitle, style = MaterialTheme.typography.bodyMedium, ) } } } } } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/setting/SettingItemNames.kt ================================================ package com.zhangke.fread.profile.screen.setting import androidx.compose.runtime.Composable import com.zhangke.fread.common.config.StatusContentSize import com.zhangke.fread.common.config.TimelineDefaultPosition import com.zhangke.fread.common.daynight.DayNightMode import com.zhangke.fread.common.language.LanguageSettingType import com.zhangke.fread.common.theme.ThemeType import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.stringResource val LanguageSettingType.typeName: String @Composable get() { return when (this) { LanguageSettingType.CN -> stringResource(LocalizedString.profileSettingLanguageZh) LanguageSettingType.EN -> stringResource(LocalizedString.profileSettingLanguageEn) LanguageSettingType.SYSTEM -> stringResource(LocalizedString.profileSettingLanguageSystem) } } val DayNightMode.modeName: String @Composable get() { return when (this) { DayNightMode.NIGHT -> stringResource(LocalizedString.profileSettingDarkModeDark) DayNightMode.DAY -> stringResource(LocalizedString.profileSettingDarkModeLight) DayNightMode.FOLLOW_SYSTEM -> stringResource(LocalizedString.profileSettingDarkModeFollowSystem) } } val StatusContentSize.sizeName: String @Composable get() = when (this) { StatusContentSize.SMALL -> stringResource(LocalizedString.profileSettingFontSizeSmall) StatusContentSize.MEDIUM -> stringResource(LocalizedString.profileSettingFontSizeMedium) StatusContentSize.LARGE -> stringResource(LocalizedString.profileSettingFontSizeLarge) } val TimelineDefaultPosition.displayName: String @Composable get() = when (this) { TimelineDefaultPosition.NEWEST -> stringResource(LocalizedString.profileSettingTimelinePositionNewest) TimelineDefaultPosition.LAST_READ -> stringResource(LocalizedString.profileSettingTimelinePositionLastRead) } val ThemeType.displayName: String @Composable get() = when (this) { ThemeType.DEFAULT -> stringResource(LocalizedString.profileSettingThemeDefault) ThemeType.SYSTEM_DYNAMIC -> stringResource(LocalizedString.profileSettingThemeSystem) } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/setting/SettingScreen.kt ================================================ package com.zhangke.fread.profile.screen.setting import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Chat import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.ViewTimeline import androidx.compose.material.icons.outlined.Coffee import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.common.handler.LocalTextHandler import com.zhangke.fread.common.language.LanguageSettingItem import com.zhangke.fread.common.language.LocalActivityLanguageHelper import com.zhangke.fread.feature.profile.Res import com.zhangke.fread.feature.profile.ic_code import com.zhangke.fread.feature.profile.ic_ratting import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.profile.screen.donate.DonateScreenNavKey import com.zhangke.fread.profile.screen.opensource.OpenSourceScreenNavKey import com.zhangke.fread.profile.screen.setting.about.AboutScreenNavKey import com.zhangke.fread.profile.screen.setting.appearance.AppearanceSettingsNavKey import com.zhangke.fread.profile.screen.setting.behavior.BehaviorSettingsNavKey import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource @Serializable object SettingScreenNavKey : NavKey @Composable fun SettingScreen(viewModel: SettingScreenModel) { val backStack = LocalNavBackStack.currentOrThrow val uiState by viewModel.uiState.collectAsState() val activityLanguageHelper = LocalActivityLanguageHelper.current val activityTextHandler = LocalTextHandler.current SettingContent( uiState = uiState, onBackClick = backStack::removeLastOrNull, onOpenSourceClick = { backStack.add(OpenSourceScreenNavKey) }, onLanguageClick = { activityLanguageHelper.setLanguage(it) }, onRatingClick = { activityTextHandler.openAppMarket() }, onAppearanceClick = { backStack.add(AppearanceSettingsNavKey) }, onBehaviorClick = { backStack.add(BehaviorSettingsNavKey) }, onAboutClick = { backStack.add(AboutScreenNavKey) }, onDonateClick = { backStack.add(DonateScreenNavKey) }, ) } @Composable private fun SettingContent( uiState: SettingUiState, onBackClick: () -> Unit, onOpenSourceClick: () -> Unit, onLanguageClick: (LanguageSettingItem) -> Unit, onRatingClick: () -> Unit, onAboutClick: () -> Unit, onDonateClick: () -> Unit, onAppearanceClick: () -> Unit, onBehaviorClick: () -> Unit, ) { Scaffold( topBar = { Toolbar( title = stringResource(LocalizedString.settings), onBackClick = onBackClick, ) }, ) { innerPadding -> Column( modifier = Modifier .padding(innerPadding) .verticalScroll(rememberScrollState()), ) { SettingItem( icon = Icons.Default.Palette, title = stringResource(LocalizedString.setting_group_appearance), subtitle = stringResource(LocalizedString.setting_group_appearance_subtitle), onClick = onAppearanceClick, ) SettingItem( icon = Icons.Default.ViewTimeline, title = stringResource(LocalizedString.setting_group_behavior), subtitle = stringResource(LocalizedString.setting_group_behavior_subtitle), onClick = onBehaviorClick, ) LanguageItem( onLanguageClick = onLanguageClick, ) FeedbackItem() SettingItem( icon = vectorResource(Res.drawable.ic_code), title = stringResource(LocalizedString.profileSettingOpenSourceTitle), subtitle = stringResource(LocalizedString.profileSettingOpenSourceDesc), onClick = onOpenSourceClick, ) SettingItem( icon = { Icon( modifier = Modifier .size(24.dp) .padding(2.dp), imageVector = vectorResource(Res.drawable.ic_ratting), contentDescription = stringResource(LocalizedString.profileSettingRatting), ) }, title = stringResource(LocalizedString.profileSettingRatting), subtitle = stringResource(LocalizedString.profileSettingRattingDesc), onClick = onRatingClick, ) SettingItem( icon = Icons.Outlined.Coffee, title = stringResource(LocalizedString.donate), subtitle = stringResource(LocalizedString.profileSettingDonateDesc), onClick = onDonateClick, ) SettingItem( icon = Icons.Outlined.Info, title = stringResource(LocalizedString.profileSettingAboutTitle), subtitle = uiState.settingInfo, redDot = uiState.haveNewAppVersion, onClick = onAboutClick, ) } } } @Composable private fun LanguageItem( onLanguageClick: (LanguageSettingItem) -> Unit, ) { val activityLanguageHelper = LocalActivityLanguageHelper.current val subtitle = activityLanguageHelper.currentLanguage.getDisplayName() SettingItemWithPopup( icon = Icons.Default.Language, title = stringResource(LocalizedString.profileSettingLanguageTitle), subtitle = subtitle, dropDownItemCount = LanguageSettingItem.items.size, dropDownItemText = { LanguageSettingItem.items[it].getDisplayName() }, onItemClick = { onLanguageClick(LanguageSettingItem.items[it]) } ) } @Composable private fun FeedbackItem() { var showFeedbackBottomSheet by remember { mutableStateOf(false) } SettingItem( icon = Icons.AutoMirrored.Outlined.Chat, title = stringResource(LocalizedString.profileSettingOpenSourceFeedback), subtitle = stringResource(LocalizedString.profileSettingOpenSourceFeedbackDesc), onClick = { showFeedbackBottomSheet = true }, ) if (showFeedbackBottomSheet) { FeedbackBottomSheet( onDismissRequest = { showFeedbackBottomSheet = false }, ) } } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/setting/SettingScreenModel.kt ================================================ package com.zhangke.fread.profile.screen.setting import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zhangke.fread.common.handler.TextHandler import com.zhangke.fread.common.update.AppUpdateManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class SettingScreenModel( private val textHandler: TextHandler, private val updateManager: AppUpdateManager, ) : ViewModel() { private val _uiState = MutableStateFlow( SettingUiState( settingInfo = getAppVersionInfo(), haveNewAppVersion = false, ) ) val uiState = _uiState.asStateFlow() init { viewModelScope.launch { if (updateManager.enableAutoCheckUpdate) { updateManager.checkForUpdate(false) .onSuccess { (needUpdate, _) -> _uiState.update { it.copy(haveNewAppVersion = needUpdate) } } } } } private fun getAppVersionInfo(): String { return "${textHandler.versionName}(${textHandler.versionCode})" } } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/setting/SettingUiState.kt ================================================ package com.zhangke.fread.profile.screen.setting data class SettingUiState( val settingInfo: String, val haveNewAppVersion: Boolean, ) ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/setting/about/AboutScreen.kt ================================================ package com.zhangke.fread.profile.screen.setting.about import androidx.compose.foundation.Image import androidx.compose.foundation.background 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.foundation.text.selection.SelectionContainer import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.noRippleClick import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.toast.toast import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.common.config.AppCommonConfig import com.zhangke.fread.common.handler.LocalTextHandler import com.zhangke.fread.commonbiz.ic_fread_logo import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.ui.update.AppUpdateDialog import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @Serializable object AboutScreenNavKey : NavKey @Composable fun AboutScreen(viewModel: AboutViewModel) { val backStack = LocalNavBackStack.currentOrThrow val uiState by viewModel.uiState.collectAsState() val snackBarState = rememberSnackbarHostState() AboutScreenContent( uiState = uiState, snackBarState = snackBarState, onBackClick = backStack::removeLastOrNull, onUpdateClick = viewModel::onUpdateClick, onCheckUpdateClick = viewModel::onCheckForUpdateClick, ) ConsumeSnackbarFlow(snackBarState, viewModel.snackBarMessage) var showUpdateDialog by rememberSaveable { mutableStateOf(false) } LaunchedEffect(uiState) { showUpdateDialog = uiState.newReleaseInfo != null } if (showUpdateDialog && uiState.newReleaseInfo != null) { AppUpdateDialog( appReleaseInfo = uiState.newReleaseInfo!!, onCancel = { showUpdateDialog = false viewModel.onCancelClick() }, onUpdateClick = { showUpdateDialog = false viewModel.onUpdateClick() }, ) } } @Composable private fun AboutScreenContent( uiState: AboutUiState, snackBarState: SnackbarHostState, onBackClick: () -> Unit, onUpdateClick: () -> Unit, onCheckUpdateClick: () -> Unit, ) { val textHandler = LocalTextHandler.current val browserLauncher = LocalActivityBrowserLauncher.current val coroutineScope = rememberCoroutineScope() Scaffold( topBar = { Toolbar( title = stringResource(LocalizedString.profileSettingAboutTitle), onBackClick = onBackClick, ) }, snackbarHost = { SnackbarHost(snackBarState) }, ) { innerPadding -> SelectionContainer { Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) .padding(horizontal = 16.dp), ) { Spacer(modifier = Modifier.height(32.dp)) Image( modifier = Modifier .align(Alignment.CenterHorizontally) .width(88.dp), painter = painterResource(com.zhangke.fread.commonbiz.Res.drawable.ic_fread_logo), contentDescription = "Logo", contentScale = ContentScale.Crop, ) Text( modifier = Modifier .padding(top = 16.dp) .align(Alignment.CenterHorizontally), text = AppCommonConfig.APP_NAME, style = MaterialTheme.typography.headlineMedium, ) Text( modifier = Modifier .padding(top = 8.dp) .align(Alignment.CenterHorizontally), text = textHandler.packageName, style = MaterialTheme.typography.bodyMedium, ) Spacer(modifier = Modifier.height(32.dp)) AboutClickableItem( title = stringResource(LocalizedString.profileAboutWebsite), clickableText = AppCommonConfig.WEBSITE, showUnderline = true, onClick = { coroutineScope.launch { browserLauncher.launchFreadLandingPage() } }, ) Spacer(modifier = Modifier.height(16.dp)) val version = remember { val versionName = textHandler.versionName val versionCode = textHandler.versionCode "$versionName($versionCode)" } Row( modifier = Modifier.fillMaxWidth().noRippleClick { onUpdateClick() }, verticalAlignment = Alignment.CenterVertically, ) { AboutClickableItem( modifier = Modifier, title = stringResource(LocalizedString.profileAboutVersion), clickableText = version, showUnderline = false, onClick = {}, ) if (uiState.newReleaseInfo != null) { Text( modifier = Modifier.padding(start = 6.dp), text = stringResource(LocalizedString.profileSettingHaveNewVersion), style = MaterialTheme.typography.labelMedium, color = LocalContentColor.current.copy(alpha = 0.7F), maxLines = 1, ) Box( modifier = Modifier.size(4.dp) .clip(CircleShape) .background(Color.Red.copy(alpha = 0.8F)), ) } } Spacer(modifier = Modifier.height(16.dp)) AboutClickableItem( title = stringResource(LocalizedString.profileAboutDeveloper), clickableText = AppCommonConfig.AUTHOR, showUnderline = true, onClick = { coroutineScope.launch { browserLauncher.launchAuthorWebsite() } }, ) Spacer(modifier = Modifier.height(16.dp)) AboutClickableItem( title = stringResource(LocalizedString.profileAboutContractUs), clickableText = AppCommonConfig.AUTHOR_EMAIL, showUnderline = false, onClick = { textHandler.copyText(AppCommonConfig.AUTHOR_EMAIL) toast("Copied to clipboard") }, ) Spacer(modifier = Modifier.height(16.dp)) AboutClickableItem( title = stringResource(LocalizedString.profileAboutTelegram), clickableText = AppCommonConfig.TELEGRAM_GROUP, showUnderline = false, onClick = { textHandler.copyText(AppCommonConfig.TELEGRAM_GROUP) browserLauncher.launchBySystemBrowser(AppCommonConfig.TELEGRAM_GROUP) }, ) Spacer(modifier = Modifier.height(16.dp)) AboutClickableItem( title = stringResource(LocalizedString.profileAboutPrivacyPolicy), clickableText = AppCommonConfig.PRIVACY_POLICY, showUnderline = false, onClick = { coroutineScope.launch { browserLauncher.launchWebTabInApp(AppCommonConfig.PRIVACY_POLICY) } }, ) Spacer(modifier = Modifier.height(24.dp)) Button( modifier = Modifier.align(Alignment.CenterHorizontally) .padding(horizontal = 42.dp), onClick = onCheckUpdateClick, ) { if (uiState.checkingUpdate) { CircularProgressIndicator( modifier = Modifier.size(18.dp), color = Color.White, ) } else { Text( text = stringResource(LocalizedString.profileSettingCheckForUpdate), ) } } } } } } @Composable private fun AboutClickableItem( modifier: Modifier = Modifier, title: String, clickableText: String, showUnderline: Boolean, onClick: () -> Unit, ) { Row( modifier = modifier.padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = Modifier .let { if (showUnderline) { it } else { it.noRippleClick { onClick() } } }, text = title, ) Text( modifier = Modifier.noRippleClick { onClick() }, text = clickableText, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodyMedium, textDecoration = if (showUnderline) TextDecoration.Underline else null, ) } } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/setting/about/AboutUiState.kt ================================================ package com.zhangke.fread.profile.screen.setting.about import com.zhangke.fread.common.update.AppReleaseInfo data class AboutUiState( val checkingUpdate: Boolean, val newReleaseInfo: AppReleaseInfo?, ) { companion object { fun default() = AboutUiState( checkingUpdate = false, newReleaseInfo = null, ) } sealed interface UpdatingState { data object Idle : UpdatingState data object Checking : UpdatingState data class Failed(val throwable: Throwable) : UpdatingState data object DoNotNeedUpdate : UpdatingState data class NeedUpdate(val appReleaseInfo: AppReleaseInfo) : UpdatingState } } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/setting/about/AboutViewModel.kt ================================================ package com.zhangke.fread.profile.screen.setting.about import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.composable.textOf import com.zhangke.fread.common.update.AppUpdateManager import com.zhangke.fread.localization.LocalizedString import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class AboutViewModel( private val updateManager: AppUpdateManager, ) : ViewModel() { private val _uiState = MutableStateFlow(AboutUiState.default()) val uiState = _uiState.asStateFlow() private val _snackBarMessage = MutableSharedFlow() val snackBarMessage = _snackBarMessage.asSharedFlow() fun onCheckForUpdateClick(showLoading: Boolean = true) { viewModelScope.launch { _uiState.update { it.copy(checkingUpdate = showLoading, newReleaseInfo = null) } updateManager.checkForUpdate(false) .onFailure { t -> _uiState.update { it.copy(checkingUpdate = false) } _snackBarMessage.emitTextMessageFromThrowable(t) }.onSuccess { (needUpdate, releaseInfo) -> _uiState.update { it.copy( checkingUpdate = false, newReleaseInfo = if (needUpdate) releaseInfo else null, ) } if (!needUpdate) { _snackBarMessage.emit(textOf(LocalizedString.profileSettingAlreadyLatestVersion)) } } } } fun onUpdateClick() { viewModelScope.launch { _uiState.value.newReleaseInfo?.let { updateManager.updateApp(it) } } } fun onCancelClick() { viewModelScope.launch { _uiState.value.newReleaseInfo?.let { updateManager.ignoreVersion(it) } } } } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/setting/appearance/AppearanceSettingsScreen.kt ================================================ package com.zhangke.fread.profile.screen.setting.appearance import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BlurOn import androidx.compose.material.icons.filled.Contrast import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.PlayCircleOutline import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.TextFields import androidx.compose.material.icons.filled.ViewTimeline import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.common.config.StatusContentSize import com.zhangke.fread.common.daynight.DayNightMode import com.zhangke.fread.common.daynight.LocalActivityDayNightHelper import com.zhangke.fread.common.theme.ThemeType import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.profile.screen.setting.SettingItemWithPopup import com.zhangke.fread.profile.screen.setting.SettingItemWithSwitch import com.zhangke.fread.profile.screen.setting.displayName import com.zhangke.fread.profile.screen.setting.modeName import com.zhangke.fread.profile.screen.setting.sizeName import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable object AppearanceSettingsNavKey : NavKey @Composable fun AppearanceSettingsScreen(viewModel: AppearanceSettingsViewModel) { val backStack = LocalNavBackStack.currentOrThrow val uiState by viewModel.uiState.collectAsState() val activityDayNightHelper = LocalActivityDayNightHelper.current val coroutineScope = rememberCoroutineScope() AppearanceSettingsContent( uiState = uiState, onBackClick = backStack::removeLastOrNull, onDayNightModeClick = { coroutineScope.launch { activityDayNightHelper.setMode(it) } }, onThemeTypeChanged = viewModel::onThemeTypeChanged, onAmoledChanged = { coroutineScope.launch { activityDayNightHelper.setAmoledMode(it) } }, onContentSizeChanged = viewModel::onContentSizeChanged, onImmersiveBarChanged = viewModel::onImmersiveBarChanged, onBlurAppBarStyleChanged = viewModel::onBlurAppBarStyleChanged, onHomeTabNextButtonVisibleChanged = viewModel::onHomeTabNextButtonVisibleChanged, onHomeTabRefreshButtonVisibleChanged = viewModel::onHomeTabRefreshButtonVisibleChanged, ) } @Composable private fun AppearanceSettingsContent( uiState: AppearanceSettingsUiState, onBackClick: () -> Unit, onDayNightModeClick: (DayNightMode) -> Unit, onThemeTypeChanged: (ThemeType) -> Unit, onAmoledChanged: (on: Boolean) -> Unit, onContentSizeChanged: (StatusContentSize) -> Unit, onImmersiveBarChanged: (on: Boolean) -> Unit, onBlurAppBarStyleChanged: (enabled: Boolean) -> Unit, onHomeTabNextButtonVisibleChanged: (on: Boolean) -> Unit, onHomeTabRefreshButtonVisibleChanged: (on: Boolean) -> Unit, ) { Scaffold( topBar = { Toolbar( title = stringResource(LocalizedString.setting_group_appearance), onBackClick = onBackClick, ) }, ) { innerPadding -> Column( modifier = Modifier .padding(innerPadding) .verticalScroll(rememberScrollState()), ) { ImmersiveNavBar( immersive = uiState.immersiveNavBar, onImmersiveBarChanged = onImmersiveBarChanged, ) SolidBarBackgroundItem( blurEnabled = uiState.blurAppBarStyleEnabled, onBlurEnabledChanged = onBlurAppBarStyleChanged, ) AmoledMode( enabled = uiState.amoledEnabled, onAmoledChanged = onAmoledChanged, ) HomeTabNextButtonItem( visible = uiState.homeTabNextButtonVisible, onVisibleChanged = onHomeTabNextButtonVisibleChanged, ) HomeTabRefreshButtonItem( visible = uiState.homeTabRefreshButtonVisible, onVisibleChanged = onHomeTabRefreshButtonVisibleChanged, ) DayNightItem( uiState = uiState, onDayNightModeClick = onDayNightModeClick, ) ContentSizeItem( contentSize = uiState.contentSize, onContentSizeChanged = onContentSizeChanged, ) ThemeTypeItem( themeType = uiState.themeType, onThemeTypeChanged = onThemeTypeChanged, ) } } } @Composable private fun SolidBarBackgroundItem( blurEnabled: Boolean, onBlurEnabledChanged: (enabled: Boolean) -> Unit, ) { val checked = !blurEnabled SettingItemWithSwitch( icon = Icons.Default.BlurOn, title = stringResource(LocalizedString.setting_item_solid_bar_background_title), subtitle = stringResource(LocalizedString.setting_item_solid_bar_background_subtitle), checked = checked, onCheckedChangeRequest = { onBlurEnabledChanged(!it) }, ) } @Composable private fun ImmersiveNavBar( immersive: Boolean, onImmersiveBarChanged: (on: Boolean) -> Unit, ) { SettingItemWithSwitch( icon = Icons.Default.PlayCircleOutline, title = stringResource(LocalizedString.profileSettingImmersiveNavBar), subtitle = stringResource(LocalizedString.profileSettingImmersiveNavBarDesc), checked = immersive, onCheckedChangeRequest = onImmersiveBarChanged, ) } @Composable private fun AmoledMode( enabled: Boolean, onAmoledChanged: (on: Boolean) -> Unit, ) { SettingItemWithSwitch( icon = Icons.Default.DarkMode, title = stringResource(LocalizedString.setting_item_amoled_mode), subtitle = stringResource(LocalizedString.setting_item_amoled_mode_description), checked = enabled, onCheckedChangeRequest = onAmoledChanged, ) } @Composable private fun DayNightItem( uiState: AppearanceSettingsUiState, onDayNightModeClick: (DayNightMode) -> Unit, ) { SettingItemWithPopup( icon = Icons.Default.Contrast, title = stringResource(LocalizedString.profileSettingDarkModeTitle), subtitle = uiState.dayNightMode.modeName, dropDownItemCount = DayNightMode.entries.size, dropDownItemText = { DayNightMode.entries[it].modeName }, onItemClick = { index -> onDayNightModeClick(DayNightMode.entries[index]) }, ) } @Composable private fun ThemeTypeItem( themeType: ThemeType, onThemeTypeChanged: (ThemeType) -> Unit, ) { SettingItemWithPopup( icon = Icons.Default.Palette, title = stringResource(LocalizedString.profileSettingThemeTitle), subtitle = themeType.displayName, dropDownItemCount = ThemeType.entries.size, dropDownItemText = { ThemeType.entries[it].displayName }, onItemClick = { onThemeTypeChanged(ThemeType.entries[it]) } ) } @Composable private fun ContentSizeItem( contentSize: StatusContentSize, onContentSizeChanged: (StatusContentSize) -> Unit, ) { SettingItemWithPopup( icon = Icons.Default.TextFields, title = stringResource(LocalizedString.profileSettingFontSize), subtitle = contentSize.sizeName, dropDownItemCount = StatusContentSize.entries.size, dropDownItemText = { StatusContentSize.entries[it].sizeName }, onItemClick = { onContentSizeChanged(StatusContentSize.entries[it]) } ) } @Composable private fun HomeTabNextButtonItem( visible: Boolean, onVisibleChanged: (on: Boolean) -> Unit, ) { SettingItemWithSwitch( icon = Icons.Default.ViewTimeline, title = stringResource(LocalizedString.setting_item_home_tab_next_title), subtitle = stringResource(LocalizedString.setting_item_home_tab_next_subtitle), checked = visible, onCheckedChangeRequest = onVisibleChanged, ) } @Composable private fun HomeTabRefreshButtonItem( visible: Boolean, onVisibleChanged: (on: Boolean) -> Unit, ) { SettingItemWithSwitch( icon = Icons.Default.Refresh, title = stringResource(LocalizedString.setting_item_home_tab_refresh_title), subtitle = stringResource(LocalizedString.setting_item_home_tab_refresh_subtitle), checked = visible, onCheckedChangeRequest = onVisibleChanged, ) } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/setting/appearance/AppearanceSettingsUiState.kt ================================================ package com.zhangke.fread.profile.screen.setting.appearance import com.zhangke.fread.common.config.StatusContentSize import com.zhangke.fread.common.daynight.DayNightMode import com.zhangke.fread.common.theme.ThemeType data class AppearanceSettingsUiState( val immersiveNavBar: Boolean, val blurAppBarStyleEnabled: Boolean, val amoledEnabled: Boolean, val dayNightMode: DayNightMode, val contentSize: StatusContentSize, val themeType: ThemeType, val homeTabNextButtonVisible: Boolean, val homeTabRefreshButtonVisible: Boolean, ) ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/setting/appearance/AppearanceSettingsViewModel.kt ================================================ package com.zhangke.fread.profile.screen.setting.appearance import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zhangke.fread.common.config.FreadConfigManager import com.zhangke.fread.common.config.StatusContentSize import com.zhangke.fread.common.daynight.DayNightHelper import com.zhangke.fread.common.theme.ThemeType import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class AppearanceSettingsViewModel( private val freadConfigManager: FreadConfigManager, private val dayNightHelper: DayNightHelper, ) : ViewModel() { private val _uiState = MutableStateFlow( AppearanceSettingsUiState( immersiveNavBar = freadConfigManager.statusConfigFlow.value.immersiveNavBar, blurAppBarStyleEnabled = freadConfigManager.enableBlurAppBarStyleFlow.value, amoledEnabled = dayNightHelper.amoledModeFlow.value, dayNightMode = dayNightHelper.dayNightModeFlow.value, contentSize = freadConfigManager.statusConfigFlow.value.contentSize, themeType = ThemeType.DEFAULT, homeTabNextButtonVisible = freadConfigManager.homeTabNextButtonVisibleFlow.value, homeTabRefreshButtonVisible = freadConfigManager.homeTabRefreshButtonVisibleFlow.value, ) ) val uiState = _uiState.asStateFlow() init { viewModelScope.launch { freadConfigManager.getThemeType() .let { type -> _uiState.update { it.copy(themeType = type) } } } viewModelScope.launch { dayNightHelper.dayNightModeFlow.collect { dayNightMode -> _uiState.update { it.copy(dayNightMode = dayNightMode) } } } viewModelScope.launch { dayNightHelper.amoledModeFlow.collect { enabled -> _uiState.update { it.copy(amoledEnabled = enabled) } } } viewModelScope.launch { freadConfigManager.statusConfigFlow.collect { config -> _uiState.update { it.copy( contentSize = config.contentSize, immersiveNavBar = config.immersiveNavBar, ) } } } viewModelScope.launch { freadConfigManager.homeTabRefreshButtonVisibleFlow.collect { visible -> _uiState.update { it.copy(homeTabRefreshButtonVisible = visible) } } } viewModelScope.launch { freadConfigManager.homeTabNextButtonVisibleFlow.collect { visible -> _uiState.update { it.copy(homeTabNextButtonVisible = visible) } } } viewModelScope.launch { freadConfigManager.enableBlurAppBarStyleFlow.collect { enabled -> _uiState.update { it.copy(blurAppBarStyleEnabled = enabled) } } } } fun onThemeTypeChanged(type: ThemeType) { viewModelScope.launch { freadConfigManager.updateThemeType(type) _uiState.update { it.copy(themeType = type) } } } fun onContentSizeChanged(contentSize: StatusContentSize) { viewModelScope.launch { freadConfigManager.updateStatusContentSize(contentSize) } } fun onImmersiveBarChanged(on: Boolean) { viewModelScope.launch { freadConfigManager.updateImmersiveNavBar(on) } } fun onHomeTabNextButtonVisibleChanged(visible: Boolean) { viewModelScope.launch { freadConfigManager.updateHomeTabNextButtonVisible(visible) } } fun onHomeTabRefreshButtonVisibleChanged(visible: Boolean) { viewModelScope.launch { freadConfigManager.updateHomeTabRefreshButtonVisible(visible) } } fun onBlurAppBarStyleChanged(enabled: Boolean) { viewModelScope.launch { freadConfigManager.updateEnableBlurAppBarStyle(enabled) } } } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/setting/behavior/BehaviorSettingsScreen.kt ================================================ package com.zhangke.fread.profile.screen.setting.behavior import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.PlayCircleOutline import androidx.compose.material.icons.filled.ViewTimeline import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.common.config.TimelineDefaultPosition import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.profile.screen.setting.SettingItemWithPopup import com.zhangke.fread.profile.screen.setting.SettingItemWithSwitch import com.zhangke.fread.profile.screen.setting.displayName import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable object BehaviorSettingsNavKey : NavKey @Composable fun BehaviorSettingsScreen(viewModel: BehaviorSettingsViewModel) { val backStack = LocalNavBackStack.currentOrThrow val uiState by viewModel.uiState.collectAsState() BehaviorSettingsContent( uiState = uiState, onBackClick = backStack::removeLastOrNull, onSwitchAutoPlayClick = viewModel::onChangeAutoPlayInlineVideo, onAlwaysShowSensitive = viewModel::onAlwaysShowSensitiveContentChanged, onTimelineDefaultPositionChanged = viewModel::onTimelineDefaultPositionChanged, onOpenUrlInAppBrowserChanged = viewModel::onOpenUrlInAppBrowserChanged, ) } @Composable private fun BehaviorSettingsContent( uiState: BehaviorSettingsUiState, onBackClick: () -> Unit, onSwitchAutoPlayClick: (on: Boolean) -> Unit, onAlwaysShowSensitive: (Boolean) -> Unit, onTimelineDefaultPositionChanged: (TimelineDefaultPosition) -> Unit, onOpenUrlInAppBrowserChanged: (Boolean) -> Unit, ) { Scaffold( topBar = { Toolbar( title = stringResource(LocalizedString.setting_group_behavior), onBackClick = onBackClick, ) }, ) { innerPadding -> Column( modifier = Modifier .padding(innerPadding) .verticalScroll(rememberScrollState()), ) { AutoPlayInlineVideoItem( autoPlay = uiState.autoPlayInlineVideo, onSwitchClick = onSwitchAutoPlayClick, ) AlwaysShowSensitiveContentItem( alwaysShowing = uiState.alwaysShowSensitiveContent, onAlwaysChanged = onAlwaysShowSensitive, ) OpenUrlBySystemBrowserItem( openUrlBySystem = !uiState.openUrlInAppBrowser, onOpenUrlBySystemChanged = { onOpenUrlInAppBrowserChanged(!it) }, ) TimelinePositionItem( position = uiState.timelineDefaultPosition, onPositionChanged = onTimelineDefaultPositionChanged, ) } } } @Composable private fun AutoPlayInlineVideoItem( autoPlay: Boolean, onSwitchClick: (on: Boolean) -> Unit, ) { SettingItemWithSwitch( icon = Icons.Default.PlayCircleOutline, title = stringResource(LocalizedString.profileSettingInlineVideoAutoPlay), subtitle = stringResource(LocalizedString.profileSettingInlineVideoAutoPlaySubtitle), checked = autoPlay, onCheckedChangeRequest = onSwitchClick, ) } @Composable private fun AlwaysShowSensitiveContentItem( alwaysShowing: Boolean, onAlwaysChanged: (on: Boolean) -> Unit, ) { SettingItemWithSwitch( icon = Icons.Default.PlayCircleOutline, title = stringResource(LocalizedString.profileSettingAlwaysShowSensitiveContent), subtitle = stringResource(LocalizedString.profileSettingAlwaysShowSensitiveContentSubtitle), checked = alwaysShowing, onCheckedChangeRequest = onAlwaysChanged, ) } @Composable private fun TimelinePositionItem( position: TimelineDefaultPosition, onPositionChanged: (TimelineDefaultPosition) -> Unit, ) { SettingItemWithPopup( icon = Icons.Default.ViewTimeline, title = stringResource(LocalizedString.profileSettingTimelinePosition), subtitle = position.displayName, dropDownItemCount = TimelineDefaultPosition.entries.size, dropDownItemText = { TimelineDefaultPosition.entries[it].displayName }, onItemClick = { onPositionChanged(TimelineDefaultPosition.entries[it]) }, ) } @Composable private fun OpenUrlBySystemBrowserItem( openUrlBySystem: Boolean, onOpenUrlBySystemChanged: (on: Boolean) -> Unit, ) { SettingItemWithSwitch( icon = Icons.Default.OpenInBrowser, title = stringResource(LocalizedString.setting_item_open_url_by_system_title), subtitle = stringResource(LocalizedString.setting_item_open_url_by_system_subtitle), checked = openUrlBySystem, onCheckedChangeRequest = onOpenUrlBySystemChanged, ) } ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/setting/behavior/BehaviorSettingsUiState.kt ================================================ package com.zhangke.fread.profile.screen.setting.behavior import com.zhangke.fread.common.config.TimelineDefaultPosition data class BehaviorSettingsUiState( val autoPlayInlineVideo: Boolean, val alwaysShowSensitiveContent: Boolean, val timelineDefaultPosition: TimelineDefaultPosition, val openUrlInAppBrowser: Boolean, ) ================================================ FILE: feature/profile/src/commonMain/kotlin/com/zhangke/fread/profile/screen/setting/behavior/BehaviorSettingsViewModel.kt ================================================ package com.zhangke.fread.profile.screen.setting.behavior import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zhangke.fread.common.config.FreadConfigManager import com.zhangke.fread.common.config.TimelineDefaultPosition import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class BehaviorSettingsViewModel( private val freadConfigManager: FreadConfigManager, ) : ViewModel() { private val _uiState = MutableStateFlow( BehaviorSettingsUiState( autoPlayInlineVideo = freadConfigManager.autoPlayInlineVideo, alwaysShowSensitiveContent = false, timelineDefaultPosition = TimelineDefaultPosition.NEWEST, openUrlInAppBrowser = freadConfigManager.openUrlInAppBrowser, ) ) val uiState = _uiState.asStateFlow() init { viewModelScope.launch { freadConfigManager.getTimelineDefaultPosition() .let { position -> _uiState.update { it.copy(timelineDefaultPosition = position) } } } viewModelScope.launch { freadConfigManager.statusConfigFlow.collect { config -> _uiState.update { it.copy(alwaysShowSensitiveContent = config.alwaysShowSensitiveContent) } } } } fun onChangeAutoPlayInlineVideo(on: Boolean) { viewModelScope.launch { freadConfigManager.updateAutoPlayInlineVideo(on) _uiState.update { it.copy(autoPlayInlineVideo = on) } } } fun onAlwaysShowSensitiveContentChanged(always: Boolean) { viewModelScope.launch { freadConfigManager.updateAlwaysShowSensitiveContent(always) } } fun onTimelineDefaultPositionChanged(position: TimelineDefaultPosition) { viewModelScope.launch { freadConfigManager.updateTimelineDefaultPosition(position) _uiState.update { it.copy(timelineDefaultPosition = position) } } } fun onOpenUrlInAppBrowserChanged(openInApp: Boolean) { viewModelScope.launch { freadConfigManager.updateOpenUrlInAppBrowser(openInApp) _uiState.update { it.copy(openUrlInAppBrowser = openInApp) } } } } ================================================ FILE: framework/.gitignore ================================================ /build ================================================ FILE: framework/build.gradle.kts ================================================ plugins { id("fread.project.framework.kmp") id("kotlin-parcelize") } android { namespace = "com.zhangke.fread.framework" sourceSets { getByName("main") { res.srcDirs("src/commonMain/res") resources.srcDirs("src/commonMain/resources") } } } kotlin { sourceSets { commonMain { dependencies { api(project(path = ":localization")) implementation(compose.components.resources) implementation(libs.compose.jb.backhandler) implementation(libs.androidx.annotation) implementation(libs.arrow.core) implementation(libs.jetbrains.lifecycle.runtime) implementation(libs.jetbrains.lifecycle.viewmodel) implementation(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.datetime) implementation(libs.ktor.client.core) implementation(libs.ktor.client.serialization.kotlinx.json) implementation(libs.ktor.client.content.negotiation) implementation(libs.haze) implementation(libs.haze.materials) implementation(libs.imageLoader) implementation(libs.okio) implementation(libs.ksoup) implementation(libs.uri.kmp) implementation(libs.bignum) implementation(libs.kermit) implementation(libs.placeholder.material3) implementation(libs.compose.media.player) implementation(libs.krouter.runtime) } } commonTest { dependencies { implementation(kotlin("test")) } } androidMain { dependencies { implementation(compose.uiTooling) implementation(compose.preview) implementation(libs.androidx.core.ktx) implementation(libs.bundles.androidx.activity) implementation(libs.accompanist.permissions) implementation(libs.okhttp3) implementation(libs.okhttp3.logging) implementation(libs.bundles.androidx.media3) implementation(libs.ktml) implementation(libs.ktor.client.okhttp) } } androidUnitTest { dependencies { } } androidInstrumentedTest { dependencies { implementation(libs.junit) implementation(libs.androidx.test.ext.junit) implementation(libs.androidx.test.espresso.core) } } iosMain { dependencies { implementation(libs.ktor.client.darwin) } } } } compose { resources { publicResClass = true packageOfResClass = "com.zhangke.fread.framework" generateResClass = always } } ================================================ FILE: framework/consumer-rules.pro ================================================ ================================================ FILE: framework/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: framework/src/androidInstrumentedTest/kotlin/com/zhangke/framework/ExampleInstrumentedTest.kt ================================================ package com.zhangke.framework import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Test import org.junit.runner.RunWith import org.junit.Assert.* /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { @Test fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.zhangke.framework.test", appContext.packageName) } } ================================================ FILE: framework/src/androidInstrumentedTest/kotlin/com/zhangke/framework/utils/UtiUtilsTest.kt ================================================ package com.zhangke.framework.utils import org.junit.Assert import org.junit.Test class UtiUtilsTest { @Test fun shouldRemovePathPrefixAndSuffix(){ val uri = uriString( scheme = "app", host = "zhangke.com", path = "/home/", queries = mapOf("name" to "zhangke"), ) Assert.assertEquals("app://zhangke.com/home?name=zhangke", uri) } @Test fun shouldRemovePathPrefixAndSuffixWhenEmptyHost(){ val uri = uriString( scheme = "app", host = "", path = "/home/", queries = mapOf("name" to "zhangke"), ) Assert.assertEquals("app://home?name=zhangke", uri) } @Test fun shouldRemovePathSuffixWhenEmptyScheme(){ val uri = uriString( scheme = "", host = "zhangke.com", path = "/home/", queries = mapOf("name" to "zhangke"), ) Assert.assertEquals("zhangke.com/home?name=zhangke", uri) } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/activity/TopActivityManager.kt ================================================ package com.zhangke.framework.activity import android.app.Activity import android.app.Application import com.zhangke.framework.utils.ActivityLifecycleCallbacksAdapter import java.lang.ref.SoftReference object TopActivityManager { private var activeActivity: SoftReference? = null val topActiveActivity: Activity? get() = activeActivity?.get() fun init(application: Application) { application.registerActivityLifecycleCallbacks( object : ActivityLifecycleCallbacksAdapter() { override fun onActivityResumed(activity: Activity) { super.onActivityResumed(activity) activeActivity = SoftReference(activity) } override fun onActivityPaused(activity: Activity) { super.onActivityPaused(activity) activeActivity?.clear() activeActivity = null } }, ) } fun updateTopActivity(activity: Activity){ activeActivity = SoftReference(activity) } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/architect/http/GlobalOkHttpClient.kt ================================================ package com.zhangke.framework.architect.http import android.annotation.SuppressLint import android.util.Log import com.zhangke.framework.utils.ifDebugging import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import java.security.KeyStore import java.util.* import java.util.concurrent.TimeUnit import javax.net.ssl.* object GlobalOkHttpClient { private const val TIMEOUT = 15L val client: OkHttpClient by lazy { createBuilder().build() } private val thirdPartInterceptors = mutableListOf() fun addThirdPartInterceptor(interceptor: Interceptor) { thirdPartInterceptors += interceptor } private fun createBuilder(): OkHttpClient.Builder { val ssl = buildSSLFactory() val builder = OkHttpClient().newBuilder() .connectTimeout(TIMEOUT, TimeUnit.SECONDS) .readTimeout(TIMEOUT, TimeUnit.SECONDS) .writeTimeout(TIMEOUT, TimeUnit.SECONDS) .sslSocketFactory(ssl.first, ssl.second) .hostnameVerifier { _, _ -> true } ifDebugging { builder.addInterceptor( HttpLoggingInterceptor(HttpLoggingInterceptor.Logger.DEFAULT) .setLevel(HttpLoggingInterceptor.Level.BODY) ) } thirdPartInterceptors.forEach { Log.d("GlobalOkHttpClient", "add $it") builder.addInterceptor(it) } return builder } @SuppressLint("TrustAllX509TrustManager", "CustomX509TrustManager") private fun buildSSLFactory(): Pair { val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) trustManagerFactory.init(null as KeyStore?) val trustManagers = trustManagerFactory.trustManagers check(!(trustManagers.size != 1 || trustManagers[0] !is X509TrustManager)) { "Unexpected default trust managers:" + Arrays.toString(trustManagers) } val trustManager = trustManagers[0] as X509TrustManager val sslContext = SSLContext.getInstance("SSL") sslContext.init(null, arrayOf(trustManager), null) sslContext.defaultSSLParameters.protocols = arrayOf("SSLv3") val sslSocketFactory = sslContext.socketFactory return Pair(sslSocketFactory, trustManager) } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/architect/http/HttpClientEngine.android.kt ================================================ package com.zhangke.framework.architect.http import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.okhttp.OkHttp actual fun createHttpClientEngine(): HttpClientEngine { return OkHttp.create { preconfigured = GlobalOkHttpClient.client } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/architect/theme/FreadTheme.android.kt ================================================ package com.zhangke.framework.architect.theme import android.app.Activity import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat @Composable internal actual fun FreadPlatformTheme(darkTheme: Boolean) { val view = LocalView.current if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme } } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/blurhash/BitmapExt.android.kt ================================================ package com.zhangke.framework.blurhash import android.graphics.Bitmap import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap actual fun bitmapFromBuffer(buffer: IntArray, width: Int, height: Int): ImageBitmap { return Bitmap.createBitmap(buffer, width, height, Bitmap.Config.ARGB_8888).asImageBitmap() } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/composable/AnimatableExt.kt ================================================ package com.zhangke.framework.composable import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import com.zhangke.framework.utils.pxToDp val Animatable.dpValue: Dp @Composable get() { return value.pxToDp(LocalDensity.current) } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/composable/Loading.android.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @Preview @Composable fun PreviewShortLoadErrorLineItem(){ LoadErrorLineItem( modifier = Modifier.fillMaxWidth(), errorMessage = "123", ) } @Preview @Composable fun PreviewLongLoadErrorLineItem(){ LoadErrorLineItem( modifier = Modifier.fillMaxWidth(), errorMessage = "123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123", ) } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/composable/TextString.android.kt ================================================ package com.zhangke.framework.composable import androidx.compose.runtime.Composable import com.zhangke.framework.utils.appContext @Composable actual fun stringResource(resId: Int, vararg formatArgs: Any): String { return androidx.compose.ui.res.stringResource(resId, *formatArgs) } actual suspend fun TextString.getString(): String { return when (this) { is TextString.StringText -> string is TextString.ResourceText -> appContext.getString(resId, *formatArgs) is TextString.ComposeResourceText -> org.jetbrains.compose.resources.getString(res, *formatArgs) } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/composable/pick/PickVisualMediaLauncherContainer.android.kt ================================================ package com.zhangke.framework.composable.pick import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.zhangke.framework.utils.PlatformUri import com.zhangke.framework.utils.buildPickVisualImageRequest import com.zhangke.framework.utils.buildPickVisualMediaRequest import com.zhangke.framework.utils.buildPickVisualVideoRequest import com.zhangke.framework.utils.toPlatformUri @Composable actual fun PickVisualMediaLauncherContainer( onResult: (List) -> Unit, maxItems: Int, content: @Composable PickVisualMediaLauncherContainerScope.() -> Unit, ) { val fileLauncher = when { maxItems > 1 -> { rememberLauncherForActivityResult( contract = ActivityResultContracts.GetMultipleContents(), onResult = { uri -> onResult(uri.take(maxItems).map { it.toPlatformUri() }) }, ) } else -> { rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent(), onResult = { uri -> uri?.let { onResult(listOf(it.toPlatformUri())) } }, ) } } val launcher = when { maxItems > 1 -> rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = maxItems), onResult = { uris -> onResult(uris.map { it.toPlatformUri() }) }, ) else -> rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia(), onResult = { uri -> uri?.let { onResult(listOf(it.toPlatformUri())) } }, ) } val scope = remember(launcher) { PickVisualMediaLauncherContainerScope(launcher, fileLauncher) } with(scope) { content() } } actual class PickVisualMediaLauncherContainerScope( private val launcher: ManagedActivityResultLauncher, private val fileLauncher: ManagedActivityResultLauncher, ) { actual fun launchImage() { launcher.launch(buildPickVisualImageRequest()) } actual fun launchMedia() { launcher.launch(buildPickVisualMediaRequest()) } actual fun launchVideo() { launcher.launch(buildPickVisualVideoRequest()) } actual fun launchImageFile() { fileLauncher.launch("image/*") } actual fun launchVideoFile() { fileLauncher.launch("video/*") } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/date/InstantFormater.android.kt ================================================ @file:OptIn(ExperimentalTime::class) package com.zhangke.framework.date import kotlinx.datetime.Instant import java.text.DateFormat import java.util.Date import java.util.Locale import kotlin.time.ExperimentalTime actual class InstantFormater { actual fun formatToMediumDate(instant: Instant): String { val dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()) val timeFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.getDefault()) val date = Date(instant.toEpochMilliseconds()) return buildString { append(dateFormat.format(date)) append(" ") append(timeFormat.format(date)) } } actual fun formatToMediumDateWithoutTime(instant: Instant): String { val dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()) val date = Date(instant.toEpochMilliseconds()) return dateFormat.format(date) } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/datetime/Instant.android.kt ================================================ package com.zhangke.framework.datetime import com.zhangke.framework.serialize.TimestampAsInstantSerializer import com.zhangke.framework.utils.Parcelize import kotlinx.serialization.Serializable //@Parcelize //@Serializable(with = TimestampAsInstantSerializer::class) //actual class Instant actual ( // actual val instant: kotlinx.datetime.Instant //) : java.io.Serializable ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/ktx/SingletonDelegate.kt ================================================ package com.zhangke.framework.ktx import kotlin.reflect.KProperty private val singletonHolder = mutableMapOf() @Suppress("FunctionName") inline fun SingletonDelegate(noinline creator: (thisRef: Any?) -> T): SingletonTypedDelegate { return SingletonTypedDelegate(T::class.java, creator) } class SingletonTypedDelegate(private val type: Class, private val creator: (Any?) -> T) { @Suppress("UNCHECKED_CAST") operator fun getValue(thisRef: Any?, property: KProperty<*>): T { return singletonHolder.getOrPut(buildKey(thisRef)) { creator(thisRef) as Any } as T } private fun buildKey(thisRef: Any?): String { return if (thisRef != null) { "${thisRef::class.java.canonicalName}@${thisRef.hashCode()}" + "_${type::class.java.canonicalName}" } else { type::class.java.canonicalName!! } } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/media/MediaFileUtil.kt ================================================ package com.zhangke.framework.media import android.content.ContentValues import android.content.Context import android.graphics.Bitmap import android.net.Uri import android.os.Environment import android.provider.MediaStore import com.seiko.imageloader.imageLoader import com.seiko.imageloader.model.ImageRequest import com.zhangke.framework.architect.http.sharedHttpClient import com.zhangke.framework.imageloader.executeSafety import com.zhangke.framework.permission.hasWriteStoragePermission import com.zhangke.framework.utils.asBitmapOrNull import com.zhangke.framework.utils.ifDebugging import com.zhangke.framework.utils.throwInDebug import io.ktor.client.request.prepareRequest import io.ktor.client.statement.bodyAsChannel import io.ktor.utils.io.jvm.javaio.copyTo import java.io.OutputStream import java.net.URLDecoder object MediaFileUtil { suspend fun saveImageToGallery(context: Context, url: String): Boolean { if (!context.hasWriteStoragePermission()) return false val contentValues = buildContentValues( fileName = "${System.currentTimeMillis()}.jpg", mediaType = "image/jpeg", directory = Environment.DIRECTORY_PICTURES, ) val uri = context.contentResolver .insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) ?: return false val outputStream = context.contentResolver.openOutputStream(uri) if (outputStream == null) { context.contentResolver.delete(uri, null, null) return false } val bitmap = downloadImage(context, url) if (bitmap == null) { context.contentResolver.delete(uri, null, null) return false } outputStream.use { out -> bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out) } return true } suspend fun saveVideoToGallery(context: Context, url: String): Boolean { if (!context.hasWriteStoragePermission()) return false try { downloadVideo(url) { contentType -> val uri = context.insertVideoMedia(contentType) ?: return@downloadVideo null val outputStream = context.contentResolver.openOutputStream(uri) ?: return@downloadVideo null outputStream } } catch (e: Throwable) { throwInDebug("saveVideoToGallery", e) return false } return true } private fun Context.insertVideoMedia(mediaType: String): Uri? { val fileExtension = mediaType.split("/").lastOrNull() ?: "mp4" val fileName = "${System.currentTimeMillis()}.$fileExtension" val contentValues = buildContentValues(fileName, mediaType, Environment.DIRECTORY_MOVIES) return contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues) } private fun buildContentValues( fileName: String, mediaType: String, directory: String, ): ContentValues { return ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) put(MediaStore.MediaColumns.MIME_TYPE, mediaType) if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { put(MediaStore.Images.Media.RELATIVE_PATH, "$directory/Fread") } } } private suspend fun downloadImage(context: Context, url: String): Bitmap? { return try { val request = ImageRequest(url) { options { isBitmap = true } } context.imageLoader.executeSafety(request).asBitmapOrNull() } catch (e: Throwable) { ifDebugging { e.printStackTrace() } null } } private suspend inline fun downloadVideo( url: String, crossinline block: (String) -> OutputStream? ) { sharedHttpClient.prepareRequest(url).execute { response -> val contentType = response.headers["Content-Type"] ?: "video/mp4" val outputStream = block(contentType) ?: return@execute response.bodyAsChannel().copyTo(outputStream) outputStream.close() } } fun queryFileName(context: Context, uri: Uri): String { context.contentResolver .query(uri, null, null, null, null) ?.use { cursor -> if (cursor.moveToFirst()) { val columnIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME) if (columnIndex >= 0) { return cursor.getString(columnIndex) } } } return try { val path = URLDecoder.decode(uri.path, "UTF-8") path.split("/").lastOrNull() ?: path } catch (e: Throwable) { uri.toString() } } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/permission/PermissionUtils.kt ================================================ package com.zhangke.framework.permission import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.os.Build import androidx.core.app.ActivityCompat fun Context.hasWriteStoragePermission(): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { true } else { val permission = Manifest.permission.WRITE_EXTERNAL_STORAGE ActivityCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/permission/RequireLocalStoragePermission.android.kt ================================================ package com.zhangke.framework.permission import android.Manifest import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext @Composable actual fun RequireLocalStoragePermission( onPermissionGranted: suspend () -> Unit, onPermissionDenied: suspend (() -> Unit), ) { val context = LocalContext.current if (context.hasWriteStoragePermission()) { LaunchedEffect(Unit) { onPermissionGranted() } } else { RequirePermission( permissionString = Manifest.permission.WRITE_EXTERNAL_STORAGE, onPermissionGranted = onPermissionGranted, onPermissionDenied = onPermissionDenied, ) } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/permission/RequirePermission.kt ================================================ package com.zhangke.framework.permission import android.content.Context import android.content.Intent import android.net.Uri 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.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.shouldShowRationale import com.zhangke.framework.composable.FreadDialog import com.zhangke.framework.utils.extractActivity import com.zhangke.fread.localization.LocalizedString import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalPermissionsApi::class) @Composable fun RequirePermission( permissionString: String, onPermissionGranted: suspend () -> Unit, onPermissionDenied: suspend (() -> Unit) = {}, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() val permissionState = rememberPermissionState( permission = permissionString, onPermissionResult = { if (!it) { coroutineScope.launch { onPermissionDenied() } } } ) val permissionStatus = permissionState.status if (permissionStatus == PermissionStatus.Granted) { LaunchedEffect(permissionState) { onPermissionGranted() } } else { if (permissionStatus.shouldShowRationale) { LaunchedEffect(permissionState) { permissionState.launchPermissionRequest() } } else { var showDeniedDialog by remember { mutableStateOf(true) } if (showDeniedDialog) { FreadDialog( title = stringResource(LocalizedString.alert), onDismissRequest = { showDeniedDialog = false }, contentText = stringResource(LocalizedString.permissionWriteExternalPermissionDenied), onNegativeClick = { showDeniedDialog = false coroutineScope.launch { onPermissionDenied() } }, onPositiveClick = { coroutineScope.launch { onPermissionDenied() } openSettingActivity(context) }, ) } } } } private fun openSettingActivity(context: Context) { val activity = context.extractActivity() ?: return val localIntent = Intent() localIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) localIntent.action = "android.settings.APPLICATION_DETAILS_SETTINGS" localIntent.data = Uri.fromParts("package", activity.packageName, null) activity.startActivity(localIntent) } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/serialize/DateSerializer.kt ================================================ package com.zhangke.framework.serialize import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import java.util.Date class DateSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.LONG) override fun deserialize(decoder: Decoder): Date { return Date(decoder.decodeLong()) } override fun serialize(encoder: Encoder, value: Date) { encoder.encodeLong(value.time) } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/toast/Toast.android.kt ================================================ package com.zhangke.framework.toast import android.os.Build import android.widget.Toast import com.zhangke.framework.utils.appContext import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.getString actual fun toast(message: String?) { toast(message, Toast.LENGTH_SHORT) } fun toast(message: String?, length: Int) { if (message.isNullOrEmpty()) return Toast.makeText(appContext, message, length).show() } private var savingToast: Toast? = null suspend fun showFileSavingToast() { val toast = Toast.makeText(appContext, getString(LocalizedString.imageSaving), Toast.LENGTH_SHORT) toast.show() savingToast = toast if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { var callback: Toast.Callback? = null callback = object : Toast.Callback() { override fun onToastHidden() { super.onToastHidden() callback?.let { toast.removeCallback(it) } savingToast = null } } toast.addCallback(callback) } } suspend fun showFileSaveSuccessToast() { savingToast?.cancel() savingToast = null Toast.makeText(appContext, getString(LocalizedString.imageSaveSuccess), Toast.LENGTH_SHORT) .show() } suspend fun showFileSaveFailedToast() { savingToast?.cancel() savingToast = null Toast.makeText(appContext, getString(LocalizedString.imageSaveFailed), Toast.LENGTH_SHORT) .show() } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/ActivityLifecycleCallbacksAdapter.kt ================================================ package com.zhangke.framework.utils import android.app.Activity import android.app.Application import android.os.Bundle abstract class ActivityLifecycleCallbacksAdapter : Application.ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { } override fun onActivityStarted(activity: Activity) { } override fun onActivityResumed(activity: Activity) { } override fun onActivityPaused(activity: Activity) { } override fun onActivityStopped(activity: Activity) { } override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { } override fun onActivityDestroyed(activity: Activity) { } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/ActivityResultContractsExt.kt ================================================ package com.zhangke.framework.utils import android.net.Uri import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable @Composable fun rememberPickVisualMediaLauncher( maxItems: Int, onResult: (List) -> Unit, ): ManagedActivityResultLauncher? { return when { maxItems < 1 -> null maxItems > 1 -> rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = maxItems), onResult = onResult, ) else -> rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia(), onResult = { uri -> uri?.let { onResult(listOf(it)) } }, ) } } @Composable fun rememberSinglePickVisualMediaLauncher( onResult: (Uri) -> Unit, ): ManagedActivityResultLauncher { return rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia(), onResult = { uri -> uri?.let { onResult(it) } }, ) } fun buildPickVisualMediaRequest(): PickVisualMediaRequest { return PickVisualMediaRequest.Builder() .setMediaType(ActivityResultContracts.PickVisualMedia.ImageAndVideo) .build() } fun buildPickVisualImageRequest(): PickVisualMediaRequest { return PickVisualMediaRequest.Builder() .setMediaType(ActivityResultContracts.PickVisualMedia.ImageOnly) .build() } fun buildPickVisualVideoRequest(): PickVisualMediaRequest { return PickVisualMediaRequest.Builder() .setMediaType(ActivityResultContracts.PickVisualMedia.VideoOnly) .build() } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/BitmapUtils.kt ================================================ package com.zhangke.framework.utils import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Rect import android.graphics.Typeface import java.io.File import java.io.FileOutputStream object BitmapUtils { fun buildBitmapWithText( width: Int, height: Int, text: String, backgroundColor: Int, ): Bitmap { val paint = Paint() val textSize = if (text.length == 1) { width * 0.6F } else { width * 0.8F / text.length } paint.textSize = textSize paint.color = Color.WHITE paint.textAlign = Paint.Align.CENTER paint.isAntiAlias = true paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) canvas.drawColor(backgroundColor) val bundle = Rect() paint.getTextBounds(text, 0, text.length, bundle) val y = height / 2F + bundle.height() / 2F - bundle.bottom canvas.drawText(text, width / 2F, y, paint) return bitmap } fun saveToFile(bitmap: Bitmap, file: File) { FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.PNG, 90, it) } } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/ContextUtils.kt ================================================ @file:JvmName("ContextUtils") package com.zhangke.framework.utils import android.app.Activity import android.app.Application import android.content.Context import android.content.ContextWrapper import android.content.Intent import android.content.pm.ApplicationInfo import androidx.lifecycle.LifecycleOwner @Volatile lateinit var appContext: Context private set fun initApplication(application: Application) { appContext = application } inline fun Context.extractTarget(): T? { var context: Context? = this while (context != null) { if (context is T) return context context = if (context is ContextWrapper) context.baseContext else null } return null } fun Context.extractActivity(): Activity? { return extractTarget() } fun Context.extractLifecycleOwner(): LifecycleOwner? { return extractTarget() } fun Context.isDebugMode(): Boolean { return (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 } fun Context.startActivityCompat(intent: Intent) { val activity = extractActivity() if (activity != null) { activity.startActivity(intent) } else { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) this.startActivity(intent) } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/DensityUtils.android.kt ================================================ package com.zhangke.framework.utils import android.content.Context import android.util.TypedValue context(context: Context) fun Int.dpToPx(): Int { return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this.toFloat(), context.resources.displayMetrics) .toInt() } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/DrawableExt.kt ================================================ package com.zhangke.framework.utils import android.graphics.drawable.Drawable fun Drawable.aspectRatio(): Float { return intrinsicWidth.toFloat() / intrinsicHeight.toFloat() } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/DrawableWrapper.kt ================================================ package com.zhangke.framework.utils import android.content.res.ColorStateList import android.graphics.Canvas import android.graphics.ColorFilter import android.graphics.PixelFormat import android.graphics.PorterDuff import android.graphics.Rect import android.graphics.Region import android.graphics.drawable.Drawable import androidx.core.graphics.drawable.DrawableCompat public open class DrawableWrapper(drawable: Drawable? = null) : Drawable(), Drawable.Callback { private var wrappedDrawable: Drawable? = null init { setWrappedDrawable(drawable) } override fun draw(canvas: Canvas) { wrappedDrawable?.draw(canvas) } override fun onBoundsChange(bounds: Rect) { wrappedDrawable?.bounds = bounds } override fun setChangingConfigurations(configs: Int) { wrappedDrawable?.changingConfigurations = configs } override fun getChangingConfigurations(): Int { return wrappedDrawable?.changingConfigurations ?: super.getChangingConfigurations() } @Suppress("deprecation") override fun setDither(dither: Boolean) { wrappedDrawable?.setDither(dither) } override fun setFilterBitmap(filter: Boolean) { wrappedDrawable?.isFilterBitmap = filter } override fun setAlpha(alpha: Int) { wrappedDrawable?.alpha = alpha } override fun setColorFilter(colorFilter: ColorFilter?) { wrappedDrawable?.colorFilter = colorFilter } override fun isStateful(): Boolean { return wrappedDrawable?.isStateful ?: false } override fun setState(stateSet: IntArray): Boolean { return wrappedDrawable?.setState(stateSet) ?: false } override fun getState(): IntArray { return wrappedDrawable?.state ?: super.getState() } override fun jumpToCurrentState() { wrappedDrawable?.jumpToCurrentState() } override fun getCurrent(): Drawable { return wrappedDrawable ?: this } override fun setVisible(visible: Boolean, restart: Boolean): Boolean { var v = super.setVisible(visible, restart) wrappedDrawable?.setVisible(visible, restart)?.let { v = it } return v } @Suppress("deprecation") override fun getOpacity(): Int { return wrappedDrawable?.opacity ?: PixelFormat.UNKNOWN } override fun getTransparentRegion(): Region? { return wrappedDrawable?.transparentRegion } override fun getIntrinsicWidth(): Int { return wrappedDrawable?.intrinsicWidth ?: -1 } override fun getIntrinsicHeight(): Int { return wrappedDrawable?.intrinsicHeight ?: -1 } override fun getMinimumWidth(): Int { return wrappedDrawable?.minimumWidth ?: super.getMinimumWidth() } override fun getMinimumHeight(): Int { return wrappedDrawable?.minimumHeight ?: super.getMinimumHeight() } override fun getPadding(padding: Rect): Boolean { return wrappedDrawable?.getPadding(padding) ?: super.getPadding(padding) } override fun invalidateDrawable(who: Drawable) { invalidateSelf() } override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) { scheduleSelf(what, `when`) } override fun unscheduleDrawable(who: Drawable, what: Runnable) { unscheduleSelf(what) } override fun onLevelChange(level: Int): Boolean { return wrappedDrawable?.setLevel(level) ?: false } override fun setAutoMirrored(mirrored: Boolean) { wrappedDrawable?.let { DrawableCompat.setAutoMirrored(it, mirrored) } } override fun isAutoMirrored(): Boolean { return wrappedDrawable?.let { DrawableCompat.isAutoMirrored(it) } ?: false } override fun setTint(tint: Int) { wrappedDrawable?.let { DrawableCompat.setTint(it, tint) } } override fun setTintList(tint: ColorStateList?) { wrappedDrawable?.let { DrawableCompat.setTintList(it, tint) } } override fun setTintMode(tintMode: PorterDuff.Mode?) { tintMode ?: return wrappedDrawable?.let { DrawableCompat.setTintMode(it, tintMode) } } override fun setHotspot(x: Float, y: Float) { wrappedDrawable?.let { DrawableCompat.setHotspot(it, x, y) } } override fun setHotspotBounds(left: Int, top: Int, right: Int, bottom: Int) { wrappedDrawable?.let { DrawableCompat.setHotspotBounds(it, left, top, right, bottom) } } public fun getWrappedDrawable(): Drawable? { return wrappedDrawable } public fun setWrappedDrawable(drawable: Drawable?) { wrappedDrawable?.callback = null wrappedDrawable = drawable drawable?.callback = this } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/ExoPlayerUtils.kt ================================================ package com.zhangke.framework.utils import android.net.Uri import androidx.media3.common.MediaItem import androidx.media3.datasource.DefaultDataSource import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource @androidx.media3.common.util.UnstableApi fun Uri.toMediaSource(): MediaSource { val defaultDataSourceFactory = DefaultDataSource.Factory(appContext) val dataSourceFactory = DefaultDataSource.Factory(appContext, defaultDataSourceFactory) return when { this.toString().contains(".m3u8") -> { HlsMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(this)) } else -> ProgressiveMediaSource.Factory(dataSourceFactory) .createMediaSource(MediaItem.fromUri(this)) } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/FileUtils.kt ================================================ package com.zhangke.framework.utils import android.content.ContentResolver import android.net.Uri import android.provider.OpenableColumns import java.io.FileNotFoundException object FileUtils { /** * @return unit is KB */ fun getFileSizeByUri(uri: Uri): StorageSize? { val contentResolver = appContext.contentResolver var bytes: Long? = null if (uri.scheme.equals(ContentResolver.SCHEME_CONTENT)) { contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null) ?.use { cursor -> val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) if (sizeIndex != -1) { cursor.moveToFirst() try { bytes = cursor.getLong(sizeIndex) } catch (_: Throwable) { // ignore } } } } if (bytes == null) { try { contentResolver.openAssetFileDescriptor(uri, "r") ?.use { bytes = it.length } } catch (_: FileNotFoundException) { // ignore } } return bytes?.let(::StorageSize) } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/IDNUtils.android.kt ================================================ package com.zhangke.framework.utils import java.net.IDN actual class IDNUtils { actual fun toASCII(input: String): String { return IDN.toASCII(input, IDN.ALLOW_UNASSIGNED) } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/ImageCompressUtils.android.kt ================================================ package com.zhangke.framework.utils import android.graphics.Bitmap import android.graphics.BitmapFactory import java.io.ByteArrayOutputStream actual class ImageCompressUtils { actual fun compress(bytes: ByteArray, targetSize: StorageSize): CompressResult { val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return CompressResult(bytes, null) if (bytes.size < targetSize.bytes) { return CompressResult(bytes, bitmap.aspectRatio) } var quality = 90 val out = ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out) val step = 3 while (out.size() > targetSize.bytes && quality > step) { out.reset() quality -= step bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out) } runCatching { bitmap.recycle() } val resultBytes = out.toByteArray() val tmpBitmap = BitmapFactory.decodeByteArray(resultBytes, 0, resultBytes.size) val resultSize = tmpBitmap?.aspectRatio runCatching { tmpBitmap?.recycle() } return CompressResult(resultBytes, resultSize) } private val Bitmap.aspectRatio: AspectRatio get() = AspectRatio(width = width.toLong(), height = height.toLong()) } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/ImageLoaderUtils.kt ================================================ package com.zhangke.framework.utils import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import com.seiko.imageloader.model.ImageResult fun ImageResult.asBitmapOrNull(): Bitmap? = when (this) { is ImageResult.OfBitmap -> bitmap is ImageResult.OfImage -> image.drawable.let { it as? BitmapDrawable }?.bitmap else -> null } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/LanguageUtils.android.kt ================================================ package com.zhangke.framework.utils actual object LanguageUtils { actual fun getAllLanguages(): List { return Locale.getAvailableLocales() .distinctBy { it.language to it.getDisplayLanguage(Locale.ENGLISH) } } } actual typealias Locale = java.util.Locale actual val Locale.languageCode: String get() = this.language actual val Locale.isO3LanguageCode: String get() = this.isO3Language actual fun Locale.getDisplayName(displayLocale: Locale): String { return this.getDisplayLanguage(displayLocale) } actual fun initLocale(language: String): Locale { return java.util.Locale(language) } actual fun getDefaultLocale(): Locale { return java.util.Locale.getDefault() } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/PlatformIgnoredOnParcel.android.kt ================================================ package com.zhangke.framework.utils import kotlinx.parcelize.IgnoredOnParcel actual typealias PlatformIgnoredOnParcel = IgnoredOnParcel ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/PlatformParcelable.android.kt ================================================ package com.zhangke.framework.utils actual typealias PlatformParcelable = android.os.Parcelable ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/PlatformTransient.android.kt ================================================ package com.zhangke.framework.utils actual typealias PlatformTransient = kotlin.jvm.Transient ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/PlatformUri.android.kt ================================================ package com.zhangke.framework.utils import android.net.Uri import androidx.core.net.toUri import com.eygraber.uri.toUri fun PlatformUri.toAndroidUri(): Uri = toString().toUri() fun Uri.toPlatformUri(): PlatformUri = toUri() ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/Serializable.android.kt ================================================ package com.zhangke.framework.utils actual typealias PlatformSerializable = java.io.Serializable ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/SignatureUtils.kt ================================================ package com.zhangke.framework.utils import android.content.Context import android.content.pm.PackageManager import android.content.pm.Signature import android.os.Build import okio.ByteString @OptIn(ExperimentalStdlibApi::class) fun Context.getApkSignatureSha1(): String? { val signature = getApkSignatures().firstOrNull() ?: return null val signatureByteString = ByteString.of(*signature.toByteArray()) val sha1 = signatureByteString.sha1().toByteArray().toHexString( HexFormat { upperCase = true bytes.bytesPerGroup = 1 bytes.groupSeparator = ":" }, ) return sha1 } private fun Context.getApkSignatures(): List { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { try { packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES) ?.signingInfo?.apkContentsSigners?.mapNotNull { it } ?: emptyList() } catch (_: Throwable) { emptyList() } } else { try { @Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES) ?.signatures?.mapNotNull { it } ?: emptyList() } catch (_: Throwable) { emptyList() } } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/SystemPageUtils.kt ================================================ package com.zhangke.framework.utils import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.widget.Toast import androidx.core.net.toUri object SystemPageUtils { fun openAppMarket(context: Context): Boolean { val uri = "market://details?id=${context.packageName}" if (!openSystemViewPage(context, uri)) { Toast.makeText(context, "Application market not found", Toast.LENGTH_SHORT).show() return false } return true } fun openSystemViewPage( context: Context, uri: String, ): Boolean { val intent = Intent(Intent.ACTION_VIEW, uri.toUri()) return try { context.startActivityCompat(intent) true } catch (_: ActivityNotFoundException) { false } } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/SystemUtils.kt ================================================ package com.zhangke.framework.utils import android.content.ClipData import android.content.ClipboardManager import android.content.Context object SystemUtils { fun getAppVersionName(context: Context): String { val pInfo = context.packageManager.getPackageInfo(context.packageName, 0) return pInfo.versionName.orEmpty() } fun getAppVersionCode(context: Context): String { val pInfo = context.packageManager.getPackageInfo(context.packageName, 0) return pInfo.versionCode.toString() } fun copyText(context: Context, text: String){ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("text", text) clipboard.setPrimaryClip(clip) } } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/UriUtils.android.kt ================================================ package com.zhangke.framework.utils import android.content.ContentResolver import android.content.Context import android.database.Cursor import android.graphics.Bitmap import android.media.ThumbnailUtils import android.net.Uri import android.provider.MediaStore import android.provider.OpenableColumns import android.webkit.MimeTypeMap import androidx.core.database.getLongOrNull import androidx.core.database.getStringOrNull import java.io.FileNotFoundException fun Uri.toContentProviderFile(context: Context): ContentProviderFile? { val contentResolver = context.contentResolver if (scheme.equals(ContentResolver.SCHEME_CONTENT)) { contentResolver?.queryNameAndSize(this) ?.use { cursor -> val size = cursor.getSize() ?: StorageSize(0L) val name = cursor.getDisplayName().orEmpty() return ContentProviderFile( uri = this.toPlatformUri(), fileName = name, size = size, mimeType = contentResolver.getType(this).orEmpty(), streamProvider = { contentResolver.openInputStream(this)?.use { it.readBytes() } } ) } } return try { contentResolver.openAssetFileDescriptor(this, "r") ?.use { descriptor -> val size = StorageSize(descriptor.length) val fileName = getAssetFileNameFromUri(this).orEmpty() val extension = getExtensionFromFileName(fileName) ContentProviderFile( uri = this.toPlatformUri(), fileName = fileName, size = size, mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) .orEmpty(), streamProvider = { descriptor.createInputStream()?.use { it.readBytes() } } ) } } catch (_: FileNotFoundException) { null } } private const val ASSET_PATH = "android_asset" private fun getAssetFileNameFromUri(uri: Uri): String? { val uriPath = uri.path ?: return null if (uri.scheme != "file" || !uriPath.contains(ASSET_PATH)) return null return uriPath.substring(uriPath.indexOf(ASSET_PATH) + ASSET_PATH.length + 1) } private fun getExtensionFromFileName(fileName: String): String? { val array = fileName.split(".") if (array.size < 2) return null return array.last() } private fun ContentResolver.queryNameAndSize(uri: Uri): Cursor? { return query(uri, arrayOf(OpenableColumns.SIZE, OpenableColumns.DISPLAY_NAME), null, null, null) } private fun Cursor.getSize(): StorageSize? { val sizeIndex = getColumnIndex(OpenableColumns.SIZE) if (sizeIndex != -1) { moveToFirst() try { return getLongOrNull(sizeIndex)?.let(::StorageSize) } catch (_: Throwable) { // ignore } } return null } private fun Cursor.getDisplayName(): String? { val nameIndex = getColumnIndex(OpenableColumns.DISPLAY_NAME) if (nameIndex != -1) { moveToFirst() try { return getStringOrNull(nameIndex) } catch (_: Throwable) { // ignore } } return null } fun Uri.getThumbnail(context: Context): Bitmap? { val filePathColumn = arrayOf(MediaStore.Images.Media.DATA) try { context.contentResolver .query(this, filePathColumn, null, null, null) ?.use { cursor -> cursor.moveToFirst() val columnIndex = cursor.getColumnIndex(filePathColumn[0]) val picturePath = cursor.getString(columnIndex) return ThumbnailUtils.createVideoThumbnail( picturePath, MediaStore.Video.Thumbnails.MICRO_KIND ) } } catch (_: Throwable) { } return null } ================================================ FILE: framework/src/androidMain/kotlin/com/zhangke/framework/utils/VideoUtils.android.kt ================================================ package com.zhangke.framework.utils import android.media.MediaMetadataRetriever import androidx.core.net.toUri actual class VideoUtils { actual fun getVideoAspect(uri: String): AspectRatio? { val retriever = MediaMetadataRetriever() retriever.setDataSource(appContext, uri.toUri()) val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) ?.toIntOrNull() val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) ?.toIntOrNull() retriever.release() if (width == null || height == null) return null return AspectRatio( width.toLong(), height.toLong() ) } } ================================================ FILE: framework/src/androidUnitTest/kotlin/com/zhangke/framework/ExampleUnitTest.kt ================================================ package com.zhangke.framework import org.junit.Test import org.junit.Assert.* /** * Example local unit test, which will execute on the development machine (host). * * See [testing documentation](http://d.android.com/tools/testing). */ class ExampleUnitTest { @Test fun addition_isCorrect() { assertEquals(4, 2 + 2) } } ================================================ FILE: framework/src/androidUnitTest/kotlin/com/zhangke/framework/RegexFactoryTest.kt ================================================ package com.zhangke.framework import org.junit.Test class RegexFactoryTest { } ================================================ FILE: framework/src/androidUnitTest/kotlin/com/zhangke/framework/date/DateParserTest.kt ================================================ package com.zhangke.framework.date import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.UtcOffset import kotlinx.datetime.toInstant import kotlinx.datetime.toJavaInstant import org.junit.Assert.assertEquals import org.junit.Test class DateParserTest { @Test fun parseISODate() { assertEquals( LocalDateTime(2021, 9, 1, 12, 0, 0) .toInstant(TimeZone.UTC) .toJavaInstant(), DateParser.parseISODate("2021-09-01T12:00:00Z"), ) } @Test fun parseRfc822Date() { assertEquals( LocalDateTime(2021, 9, 1, 12, 0, 0) .toInstant(TimeZone.UTC) .toJavaInstant(), DateParser.parseRfc822Date("Wed, 01 Sep 2021 12:00:00 GMT"), ) } @Test fun parseRfc3339Date() { assertEquals( LocalDateTime(2020, 7, 31, 9, 16, 15) .toInstant(UtcOffset(2)) .toJavaInstant(), DateParser.parseRfc3339Date("2020-07-31T09:16:15+02:00"), ) } @Test fun parseISO8601() { assertEquals( LocalDateTime(2021, 9, 1, 12, 0, 0) .toInstant(TimeZone.UTC) .toJavaInstant(), DateParser.parseISO8601("2021-09-01T12:00:00Z"), ) } } ================================================ FILE: framework/src/androidUnitTest/kotlin/com/zhangke/framework/feeds/fetcher/FeedsFetcherTest.kt ================================================ package com.zhangke.framework.feeds.fetcher class FeedsFetcherTest { } ================================================ FILE: framework/src/androidUnitTest/kotlin/com/zhangke/framework/feeds/fetcher/FeedsGeneratorTest.kt ================================================ package com.zhangke.framework.feeds.fetcher import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Test class FeedsGeneratorTest { @Test fun normalCase() = runBlocking { } } ================================================ FILE: framework/src/androidUnitTest/kotlin/com/zhangke/framework/utils/RegexFactoryTest.kt ================================================ package com.zhangke.framework.utils import org.junit.Test class RegexFactoryTest { @Test fun test(){ val find = RegexFactory.domainRegex.find("https://m3.material.io") println(find?.value) } } ================================================ FILE: framework/src/androidUnitTest/kotlin/com/zhangke/framework/utils/StorageSizeTest.kt ================================================ package com.zhangke.framework.utils import org.junit.Test class StorageSizeTest { @Test fun test() { val bytes = 1L * 1024 * 1024 * 1024 val storageSize = StorageSize(bytes) println(storageSize.bytes) println(storageSize.KB) println(storageSize.MB) println(storageSize.GB) } } ================================================ FILE: framework/src/androidUnitTest/kotlin/com/zhangke/framework/utils/WebFingerTest.kt ================================================ package com.zhangke.framework.utils import com.zhangke.framework.network.FormalBaseUrl import org.junit.Assert import org.junit.Test /** * Supported: * - jw@jakewharton.com * - @jw@jakewharton.com * - acct:@jw@jakewharton.com * - https://m.cmx.im/@jw@jakewharton.com * - https://m.cmx.im/@AtomZ * - https://m.cmx.im/xxx/AtomZ * - m.cmx.im/@jw@jakewharton.com * - jakewharton.com/@jw */ internal class WebFingerTest { @Test fun `should return null when content is empty`() { assert(WebFinger.create("") == null) } @Test fun `should return null when content is not a web finger`() { assert(WebFinger.create("Supported") == null) } @Test fun `should return WebFinger when acct and base url is present`() { val baseUrl = FormalBaseUrl.parse("https://m.cmx.im")!! val webFinger = WebFinger.create("@jw@jakewharton.com", baseUrl) assert(webFinger!!.name == "jw") assert(webFinger.host == "jakewharton.com") } @Test fun `should return WebFinger when acct without @ and base url is present`() { val baseUrl = FormalBaseUrl.parse("https://m.cmx.im")!! val webFinger = WebFinger.create("jw@jakewharton.com", baseUrl) assert(webFinger!!.name == "jw") assert(webFinger.host == "jakewharton.com") } @Test fun `should return WebFinger when acct just a name and base url is present`() { val baseUrl = FormalBaseUrl.parse("https://m.cmx.im")!! val webFinger = WebFinger.create("@jw", baseUrl) assert(webFinger!!.name == "jw") assert(webFinger.host == "m.cmx.im") } @Test fun `should return WebFinger when acct just a name without @ and base url is present`() { val baseUrl = FormalBaseUrl.parse("https://m.cmx.im")!! val webFinger = WebFinger.create("jw", baseUrl) assert(webFinger!!.name == "jw") assert(webFinger.host == "m.cmx.im") } @Test fun `should return WebFinger when content prefix not @`() { val webFinger = WebFinger.create("jw@jakewharton.com") assert(webFinger!!.name == "jw") assert(webFinger.host == "jakewharton.com") } @Test fun `should return WebFinger when content is standard`() { val webFinger = WebFinger.create("@jw@jakewharton.com") assert(webFinger!!.name == "jw") assert(webFinger.host == "jakewharton.com") } @Test fun `should return null when content is have two @ prefix`() { assert(WebFinger.create("@@jw@jakewharton.com") == null) } @Test fun `should return null when content is have two @ prefix and two @@ on domain`() { assert(WebFinger.create("@@jw@@jakewharton.com") == null) } @Test fun `should return null when content is domain`() { assert(WebFinger.create("jakewharton.com") == null) } @Test fun `should return WebFinger when content have acct`() { assert(WebFinger.create("acct:@jw@jakewharton.com") != null) } @Test fun `should return WebFinger when content is url`() { // https://m.cmx.im/@jw@jakewharton.com val webFinger = WebFinger.create("https://m.cmx.im/@jw@jakewharton.com") assert(webFinger != null) assert(webFinger!!.name == "jw") assert(webFinger.host == "jakewharton.com") } @Test fun `should return WebFinger when url path no domain`() { val webFinger = WebFinger.create("https://m.cmx.im/@AtomZ") assert(webFinger != null) assert(webFinger!!.name == "AtomZ") assert(webFinger.host == "m.cmx.im") } @Test fun `should return WebFinger when url path no protocol`() { val webFinger = WebFinger.create("m.cmx.im/@jw@jakewharton.com") assert(webFinger != null) assert(webFinger!!.name == "jw") assert(webFinger.host == "jakewharton.com") } @Test fun `should return WebFinger when url path no protocol no domain`() { val webFinger = WebFinger.create("jakewharton.com/@jw") assert(webFinger != null) assert(webFinger!!.name == "jw") assert(webFinger.host == "jakewharton.com") } @Test fun `should return WebFinger when url container - symbol`() { val webFinger = WebFinger.create("https://ramen-fsm.eu.org/@midX")!! Assert.assertEquals("@midX@ramen-fsm.eu.org", webFinger.toString()) } } ================================================ FILE: framework/src/commonMain/kotlin/com/sd/lib/compose/wheel_picker/WheelPicker.kt ================================================ package com.sd.lib.compose.wheel_picker import androidx.compose.animation.core.DecayAnimationSpec import androidx.compose.animation.core.calculateTargetValue import androidx.compose.animation.core.exponentialDecay import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyRow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import com.zhangke.framework.utils.Log interface FWheelPickerContentScope { val state: FWheelPickerState } interface FWheelPickerDisplayScope : FWheelPickerContentScope { @Composable fun Content(index: Int) } @Composable fun FVerticalWheelPicker( modifier: Modifier = Modifier, count: Int, state: FWheelPickerState = rememberFWheelPickerState(), key: ((index: Int) -> Any)? = null, itemHeight: Dp = 35.dp, unfocusedCount: Int = 1, userScrollEnabled: Boolean = true, reverseLayout: Boolean = false, debug: Boolean = false, focus: @Composable () -> Unit = { FWheelPickerFocusVertical() }, display: @Composable FWheelPickerDisplayScope.(index: Int) -> Unit = { DefaultWheelPickerDisplay(it) }, content: @Composable FWheelPickerContentScope.(index: Int) -> Unit, ) { WheelPicker( modifier = modifier, isVertical = true, count = count, state = state, key = key, itemSize = itemHeight, unfocusedCount = unfocusedCount, userScrollEnabled = userScrollEnabled, reverseLayout = reverseLayout, debug = debug, focus = focus, display = display, content = content, ) } @Composable fun FHorizontalWheelPicker( modifier: Modifier = Modifier, count: Int, state: FWheelPickerState = rememberFWheelPickerState(), key: ((index: Int) -> Any)? = null, itemWidth: Dp = 35.dp, unfocusedCount: Int = 1, userScrollEnabled: Boolean = true, reverseLayout: Boolean = false, debug: Boolean = false, focus: @Composable () -> Unit = { FWheelPickerFocusHorizontal() }, display: @Composable FWheelPickerDisplayScope.(index: Int) -> Unit = { DefaultWheelPickerDisplay(it) }, content: @Composable FWheelPickerContentScope.(index: Int) -> Unit, ) { WheelPicker( modifier = modifier, isVertical = false, count = count, state = state, key = key, itemSize = itemWidth, unfocusedCount = unfocusedCount, userScrollEnabled = userScrollEnabled, reverseLayout = reverseLayout, debug = debug, focus = focus, display = display, content = content, ) } @Composable private fun WheelPicker( modifier: Modifier, isVertical: Boolean, count: Int, state: FWheelPickerState, key: ((index: Int) -> Any)?, itemSize: Dp, unfocusedCount: Int, userScrollEnabled: Boolean, reverseLayout: Boolean, debug: Boolean, focus: @Composable () -> Unit, display: @Composable FWheelPickerDisplayScope.(index: Int) -> Unit, content: @Composable FWheelPickerContentScope.(index: Int) -> Unit, ) { require(count >= 0) { "require count >= 0" } require(unfocusedCount >= 0) { "require unfocusedCount >= 0" } state.debug = debug LaunchedEffect(state, count) { state.updateCount(count) } val nestedScrollConnection = remember(state) { WheelPickerNestedScrollConnection(state) }.apply { this.isVertical = isVertical this.itemSizePx = with(LocalDensity.current) { itemSize.roundToPx() } this.reverseLayout = reverseLayout } val totalSize = remember(itemSize, unfocusedCount) { itemSize * (unfocusedCount * 2 + 1) } val displayScope = remember(state) { FWheelPickerDisplayScopeImpl(state) }.apply { this.content = content } Box( modifier = modifier .nestedScroll(nestedScrollConnection) .run { if (totalSize > 0.dp) { if (isVertical) { height(totalSize).widthIn(40.dp) } else { width(totalSize).heightIn(40.dp) } } else { this } }, contentAlignment = Alignment.Center, ) { val lazyListScope: LazyListScope.() -> Unit = { repeat(unfocusedCount) { item(contentType = "placeholder") { ItemSizeBox( isVertical = isVertical, itemSize = itemSize, ) } } items( count = count, key = key, ) { index -> ItemSizeBox( isVertical = isVertical, itemSize = itemSize, ) { displayScope.display(index) } } repeat(unfocusedCount) { item(contentType = "placeholder") { ItemSizeBox( isVertical = isVertical, itemSize = itemSize, ) } } } if (isVertical) { LazyColumn( state = state.lazyListState, horizontalAlignment = Alignment.CenterHorizontally, reverseLayout = reverseLayout, userScrollEnabled = userScrollEnabled, modifier = Modifier.matchParentSize(), content = lazyListScope, ) } else { LazyRow( state = state.lazyListState, verticalAlignment = Alignment.CenterVertically, reverseLayout = reverseLayout, userScrollEnabled = userScrollEnabled, modifier = Modifier.matchParentSize(), content = lazyListScope, ) } ItemSizeBox( modifier = Modifier.align(Alignment.Center), isVertical = isVertical, itemSize = itemSize, ) { focus() } } } @Composable private fun ItemSizeBox( modifier: Modifier = Modifier, isVertical: Boolean, itemSize: Dp, content: @Composable () -> Unit = { }, ) { Box( modifier .run { if (isVertical) { height(itemSize) } else { width(itemSize) } }, contentAlignment = Alignment.Center, ) { content() } } private class WheelPickerNestedScrollConnection( private val state: FWheelPickerState, ) : NestedScrollConnection { var isVertical: Boolean = true var itemSizePx: Int = 0 var reverseLayout: Boolean = false override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { state.synchronizeCurrentIndexSnapshot() return super.onPostScroll(consumed, available, source) } override suspend fun onPreFling(available: Velocity): Velocity { val currentIndex = state.synchronizeCurrentIndexSnapshot() return if (currentIndex >= 0) { available.flingItemCount( isVertical = isVertical, itemSize = itemSizePx, decay = exponentialDecay(2f), reverseLayout = reverseLayout, ).let { flingItemCount -> if (flingItemCount == 0) { state.animateScrollToIndex(currentIndex) } else { state.animateScrollToIndex(currentIndex - flingItemCount) } } available } else { super.onPreFling(available) } } } private fun Velocity.flingItemCount( isVertical: Boolean, itemSize: Int, decay: DecayAnimationSpec, reverseLayout: Boolean, ): Int { if (itemSize <= 0) return 0 val velocity = if (isVertical) y else x val targetValue = decay.calculateTargetValue(0f, velocity) val flingItemCount = (targetValue / itemSize).toInt() return if (reverseLayout) -flingItemCount else flingItemCount } private class FWheelPickerDisplayScopeImpl( override val state: FWheelPickerState, ) : FWheelPickerDisplayScope { var content: @Composable FWheelPickerContentScope.(index: Int) -> Unit by mutableStateOf({}) @Composable override fun Content(index: Int) { content(index) } } internal inline fun logMsg(debug: Boolean, noinline block: () -> String) { if (debug) { Log.i("FWheelPicker", message = block) } } ================================================ FILE: framework/src/commonMain/kotlin/com/sd/lib/compose/wheel_picker/WheelPickerDefault.kt ================================================ package com.sd.lib.compose.wheel_picker import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box 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.width import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /** * The default implementation of focus view in vertical. */ @Composable fun FWheelPickerFocusVertical( modifier: Modifier = Modifier, dividerSize: Dp = 1.dp, dividerColor: Color = DefaultDividerColor, ) { Box( modifier = modifier.fillMaxSize() ) { Box( modifier = Modifier .background(dividerColor) .height(dividerSize) .fillMaxWidth() .align(Alignment.TopCenter), ) Box( modifier = Modifier .background(dividerColor) .height(dividerSize) .fillMaxWidth() .align(Alignment.BottomCenter), ) } } /** * The default implementation of focus view in horizontal. */ @Composable fun FWheelPickerFocusHorizontal( modifier: Modifier = Modifier, dividerSize: Dp = 1.dp, dividerColor: Color = DefaultDividerColor, ) { Box( modifier = modifier.fillMaxSize() ) { Box( modifier = Modifier .background(dividerColor) .width(dividerSize) .fillMaxHeight() .align(Alignment.CenterStart), ) Box( modifier = Modifier .background(dividerColor) .width(dividerSize) .fillMaxHeight() .align(Alignment.CenterEnd), ) } } /** * Default divider color. */ private val DefaultDividerColor: Color @Composable get() { val color = if (isSystemInDarkTheme()) Color.White else Color.Black return color.copy(alpha = 0.2f) } /** * Default display. */ @Composable fun FWheelPickerDisplayScope.DefaultWheelPickerDisplay( index: Int, ) { val focused = index == state.currentIndexSnapshot val targetScale = if (focused) 1.0f else 0.8f val animateScale by animateFloatAsState(targetScale, label = "") Box( modifier = Modifier.graphicsLayer { this.alpha = if (focused) 1.0f else 0.3f this.scaleX = animateScale this.scaleY = animateScale } ) { Content(index) } } ================================================ FILE: framework/src/commonMain/kotlin/com/sd/lib/compose/wheel_picker/WheelPickerState.kt ================================================ package com.sd.lib.compose.wheel_picker import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.math.absoluteValue @Composable fun rememberFWheelPickerState( initialIndex: Int = 0 ): FWheelPickerState { return rememberSaveable(saver = FWheelPickerState.Saver) { FWheelPickerState( initialIndex = initialIndex, ) } } class FWheelPickerState( initialIndex: Int = 0, ) { internal var debug = false internal val lazyListState = LazyListState() private var _count = 0 private var _currentIndex by mutableIntStateOf(-1) private var _currentIndexSnapshot by mutableIntStateOf(-1) private var _pendingIndex: Int? = initialIndex.coerceAtLeast(0) set(value) { logMsg(debug) { "pendingIndex:$value" } field = value?.also { check(it >= 0) check(_count == 0) } } private var _pendingIndexContinuation: Continuation? = null /** * Index of picker when it is idle, -1 means that there is no data. * * Note that this property is observable and if you use it in the composable function * it will be recomposed on every change. */ val currentIndex: Int get() = _currentIndex /** * Index of picker when it is idle or drag but not fling, -1 means that there is no data. * * Note that this property is observable and if you use it in the composable function * it will be recomposed on every change. */ val currentIndexSnapshot: Int get() = _currentIndexSnapshot /** * [LazyListState.interactionSource] */ val interactionSource: InteractionSource get() = lazyListState.interactionSource /** * [LazyListState.isScrollInProgress] */ val isScrollInProgress: Boolean get() = lazyListState.isScrollInProgress suspend fun animateScrollToIndex(index: Int) { logMsg(debug) { "animateScrollToIndex index:$index count:$_count" } @Suppress("NAME_SHADOWING") val index = index.coerceAtLeast(0) lazyListState.animateScrollToItem(index) synchronizeCurrentIndex() } suspend fun scrollToIndex(index: Int, pending: Boolean = true) { logMsg(debug) { "scrollToIndex index:$index pending:$pending count:$_count" } @Suppress("NAME_SHADOWING") val index = index.coerceAtLeast(0) lazyListState.scrollToItem(index) synchronizeCurrentIndex() if (pending) { awaitIndex(index) } } private suspend fun awaitIndex(index: Int) { if (index < 0) return if (_count > 0) return if (_currentIndex == index) return logMsg(debug) { "awaitIndex:$index start" } // Resume last continuation before suspend. resumeAwaitIndex() suspendCancellableCoroutine { cont -> _pendingIndex = index _pendingIndexContinuation = cont cont.invokeOnCancellation { logMsg(debug) { "awaitIndex:$index canceled" } _pendingIndex = null _pendingIndexContinuation = null } } logMsg(debug) { "awaitIndex:$index finish" } } private fun resumeAwaitIndex() { _pendingIndexContinuation?.let { logMsg(debug) { "resumeAwaitIndex pendingIndex:$_pendingIndex" } _pendingIndexContinuation = null it.resume(Unit) } } internal suspend fun updateCount(count: Int) { logMsg(debug) { "updateCount count:$count currentIndex:$_currentIndex" } _count = count val maxIndex = count - 1 if (maxIndex < _currentIndex) { scrollToIndex(maxIndex, pending = false) } if (count > 0) { _pendingIndex?.let { pendingIndex -> logMsg(debug) { "found pendingIndex:$pendingIndex" } scrollToIndex(pendingIndex, pending = false) _pendingIndex = null resumeAwaitIndex() } if (_currentIndex < 0) { synchronizeCurrentIndex() } } } private fun synchronizeCurrentIndex() { val index = synchronizeCurrentIndexSnapshot().coerceAtLeast(-1) if (_currentIndex != index) { logMsg(debug) { "setCurrentIndex:$index" } _currentIndex = index _currentIndexSnapshot = index } } internal fun synchronizeCurrentIndexSnapshot(): Int { return (mostStartItemInfo()?.index ?: -1).also { _currentIndexSnapshot = it } } /** * The item closest to the viewport start. */ private fun mostStartItemInfo(): LazyListItemInfo? { if (_count <= 0) return null val layoutInfo = lazyListState.layoutInfo val listInfo = layoutInfo.visibleItemsInfo if (listInfo.isEmpty()) return null if (listInfo.size == 1) return listInfo.first() val firstItem = listInfo.first() val firstOffsetDelta = (firstItem.offset - layoutInfo.viewportStartOffset).absoluteValue return if (firstOffsetDelta < firstItem.size / 2) { firstItem } else { listInfo[1] } } companion object { val Saver: Saver = listSaver( save = { listOf( it.currentIndex, ) }, restore = { FWheelPickerState( initialIndex = it[0] as Int, ) } ) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/architect/coroutines/ApplicationScope.kt ================================================ package com.zhangke.framework.architect.coroutines import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob val ApplicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/architect/coroutines/Flows.kt ================================================ package com.zhangke.framework.architect.coroutines import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.launch /** * Created by ZhangKe on 2022/10/14. */ fun Flow.collectWithLifecycle(lifecycle: LifecycleOwner, collector: FlowCollector) { lifecycle.lifecycleScope .launch { collect(collector) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/architect/http/HttpClientEngine.kt ================================================ package com.zhangke.framework.architect.http import com.zhangke.framework.architect.json.globalJson import io.ktor.client.HttpClient import io.ktor.client.engine.HttpClientEngine import io.ktor.client.plugins.DefaultRequest import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.header import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json val sharedHttpClient: HttpClient by lazy { createHttpClient(globalJson, createHttpClientEngine()) } expect fun createHttpClientEngine(): HttpClientEngine private fun createHttpClient( json: Json, engine: HttpClientEngine, ): HttpClient { return HttpClient(engine) { install(ContentNegotiation) { json(json) } install(DefaultRequest) { header(HttpHeaders.ContentType, ContentType.Application.Json) } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/architect/json/Json.kt ================================================ package com.zhangke.framework.architect.json import com.zhangke.krouter.KRouter import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.longOrNull import kotlinx.serialization.modules.SerializersModule val globalJson: Json by lazy { Json { ignoreUnknownKeys = true // use default value if JSON value is null but the property type is non-nullable. coerceInputValues = true serializersModule = SerializersModule { KRouter.getServices().forEach { with(it) { buildSerializersModule() } } } } } inline fun Json.fromJson(jsonObject: JsonElement): T { return decodeFromJsonElement(jsonObject) } inline fun Json.fromJson(jsonString: String): T { return decodeFromString(jsonString) } inline fun JsonObject.getStringOrNull(key: String): String? { return this.getJsonPrimitiveOrNull(key)?.contentOrNull } inline fun JsonObject.getLongOrNull(key: String): Long? { return this.getJsonPrimitiveOrNull(key)?.longOrNull } inline fun JsonObject.getIntOrNull(key: String): Int? { return this.getJsonPrimitiveOrNull(key)?.intOrNull } inline fun JsonObject.getJsonPrimitiveOrNull(key: String): JsonPrimitive? { return this[key]?.let { it as? JsonPrimitive } } val JsonObject.Companion.Empty get() = JsonObject(emptyMap()) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/architect/json/JsonModuleBuilder.kt ================================================ package com.zhangke.framework.architect.json import kotlinx.serialization.modules.SerializersModuleBuilder interface JsonModuleBuilder { fun SerializersModuleBuilder.buildSerializersModule() } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/architect/theme/Color.kt ================================================ package com.zhangke.framework.architect.theme import androidx.compose.material3.ColorScheme import androidx.compose.ui.graphics.Color val primaryLight = Color(0xFF2E87FF) val onPrimaryLight = Color(0xFFFFFFFF) val primaryContainerLight = Color(0xFF689CFF) val onPrimaryContainerLight = Color(0xFFFFFFFF) val secondaryLight = Color(0xFF495E8A) val onSecondaryLight = Color(0xFFFFFFFF) val secondaryContainerLight = Color(0xFFC0D3FF) val onSecondaryContainerLight = Color(0xFF283E68) val tertiaryLight = Color(0xFFF9338E) val onTertiaryLight = Color(0xFFFFFFFF) val tertiaryContainerLight = Color(0xFFFF84AF) val onTertiaryContainerLight = Color(0xFFFFFFFF) val errorLight = Color(0xFFA0004C) val onErrorLight = Color(0xFFFFFFFF) val errorContainerLight = Color(0xFFE40070) val onErrorContainerLight = Color(0xFFFFFFFF) val backgroundLight = Color(0xFFF9F9FF) val onBackgroundLight = Color(0xFF191B22) val surfaceLight = Color(0xFFF9FBFF) val onSurfaceLight = Color(0xFF191B22) val surfaceVariantLight = Color(0xFFDEE2F2) val onSurfaceVariantLight = Color(0xE0424753) val outlineLight = Color(0xFF727785) val outlineVariantLight = Color(0xFFC2C6D6) val scrimLight = Color(0xFF000000) val inverseSurfaceLight = Color(0xFF2E3038) val inverseOnSurfaceLight = Color(0xFFEFF0FA) val inversePrimaryLight = Color(0xFFADC6FF) val surfaceDimLight = Color(0xFFD8D9E3) val surfaceBrightLight = Color(0xFFF9F9FF) val surfaceContainerLowestLight = Color(0xFFFFFFFF) val surfaceContainerLowLight = Color(0xFFF2F3FD) val surfaceContainerLight = Color(0xFFECEDF7) val surfaceContainerHighLight = Color(0xFFE6E8F1) val surfaceContainerHighestLight = Color(0xFFE1E2EC) val primaryDark = Color(0xFFADC6FF) val onPrimaryDark = Color(0xFF002E69) val primaryContainerDark = Color(0xFF2171E2) val onPrimaryContainerDark = Color(0xFFFFFFFF) val secondaryDark = Color(0xFFB1C6F8) val onSecondaryDark = Color(0xFF183059) val secondaryContainerDark = Color(0xFF2A4069) val onSecondaryContainerDark = Color(0xFFC4D5FF) val tertiaryDark = Color(0xFFFFB0C8) val onTertiaryDark = Color(0xFF650033) val tertiaryContainerDark = Color(0xFFDE167A) val onTertiaryContainerDark = Color(0xFFFFFFFF) val errorDark = Color(0xFFFFB1C4) val onErrorDark = Color(0xFF65002E) val errorContainerDark = Color(0xFFE3006F) val onErrorContainerDark = Color(0xFFFFFFFF) val backgroundDark = Color(0xFF10131A) val onBackgroundDark = Color(0xFFE1E2EC) val surfaceDark = Color(0xFF10131A) val onSurfaceDark = Color(0xFFE1E2EC) val surfaceVariantDark = Color(0xFF424753) val onSurfaceVariantDark = Color(0xFFC2C6D6) val outlineDark = Color(0xFF8C909F) val outlineVariantDark = Color(0xFF424753) val scrimDark = Color(0xFF000000) val inverseSurfaceDark = Color(0xFFE1E2EC) val inverseOnSurfaceDark = Color(0xFF2E3038) val inversePrimaryDark = Color(0xFF005AC1) val surfaceDimDark = Color(0xFF10131A) val surfaceBrightDark = Color(0xFF363941) val surfaceContainerLowestDark = Color(0xFF0B0E15) val surfaceContainerLowDark = Color(0xFF191B22) val surfaceContainerDark = Color(0xFF1D2027) val surfaceContainerHighDark = Color(0xFF272A31) val surfaceContainerHighestDark = Color(0xFF32353C) val ColorScheme.dialogScrim: Color get() = Color(0x66000000) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/architect/theme/FreadTheme.kt ================================================ package com.zhangke.framework.architect.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material.Colors import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color val freadLightScheme = lightColorScheme( primary = primaryLight, onPrimary = onPrimaryLight, primaryContainer = primaryContainerLight, onPrimaryContainer = onPrimaryContainerLight, secondary = secondaryLight, onSecondary = onSecondaryLight, secondaryContainer = secondaryContainerLight, onSecondaryContainer = onSecondaryContainerLight, tertiary = tertiaryLight, onTertiary = onTertiaryLight, tertiaryContainer = tertiaryContainerLight, onTertiaryContainer = onTertiaryContainerLight, error = errorLight, onError = onErrorLight, errorContainer = errorContainerLight, onErrorContainer = onErrorContainerLight, background = backgroundLight, onBackground = onBackgroundLight, surface = surfaceLight, onSurface = onSurfaceLight, surfaceVariant = surfaceVariantLight, onSurfaceVariant = onSurfaceVariantLight, outline = outlineLight, outlineVariant = outlineVariantLight, scrim = scrimLight, inverseSurface = inverseSurfaceLight, inverseOnSurface = inverseOnSurfaceLight, inversePrimary = inversePrimaryLight, surfaceDim = surfaceDimLight, surfaceBright = surfaceBrightLight, surfaceContainerLowest = surfaceContainerLowestLight, surfaceContainerLow = surfaceContainerLowLight, surfaceContainer = surfaceContainerLight, surfaceContainerHigh = surfaceContainerHighLight, surfaceContainerHighest = surfaceContainerHighestLight, ) val freadDarkScheme = darkColorScheme( primary = primaryDark, onPrimary = onPrimaryDark, primaryContainer = primaryContainerDark, onPrimaryContainer = onPrimaryContainerDark, secondary = secondaryDark, onSecondary = onSecondaryDark, secondaryContainer = secondaryContainerDark, onSecondaryContainer = onSecondaryContainerDark, tertiary = tertiaryDark, onTertiary = onTertiaryDark, tertiaryContainer = tertiaryContainerDark, onTertiaryContainer = onTertiaryContainerDark, error = errorDark, onError = onErrorDark, errorContainer = errorContainerDark, onErrorContainer = onErrorContainerDark, background = backgroundDark, onBackground = onBackgroundDark, surface = surfaceDark, onSurface = onSurfaceDark, surfaceVariant = surfaceVariantDark, onSurfaceVariant = onSurfaceVariantDark, outline = outlineDark, outlineVariant = outlineVariantDark, scrim = scrimDark, inverseSurface = inverseSurfaceDark, inverseOnSurface = inverseOnSurfaceDark, inversePrimary = inversePrimaryDark, surfaceDim = surfaceDimDark, surfaceBright = surfaceBrightDark, surfaceContainerLowest = surfaceContainerLowestDark, surfaceContainerLow = surfaceContainerLowDark, surfaceContainer = surfaceContainerDark, surfaceContainerHigh = surfaceContainerHighDark, surfaceContainerHighest = surfaceContainerHighestDark, ) @Composable fun FreadTheme( darkTheme: Boolean = isSystemInDarkTheme(), amoledMode: Boolean = false, dynamicColors: ColorScheme? = null, content: @Composable () -> Unit ) { val freadTheme = if (darkTheme) { if (amoledMode) { freadDarkScheme.copy( background = Color.Black, surface = Color.Black, surfaceContainer = Color.Black, ) } else { freadDarkScheme } } else { freadLightScheme } val colorScheme = dynamicColors?.copy( tertiary = freadTheme.tertiary, onTertiary = freadTheme.onTertiary, tertiaryContainer = freadTheme.tertiaryContainer, onTertiaryContainer = freadTheme.onTertiaryContainer, ) ?: freadTheme FreadPlatformTheme(darkTheme) androidx.compose.material.MaterialTheme( colors = colorScheme.toColors(!darkTheme), content = { MaterialTheme( colorScheme = colorScheme, content = content, ) } ) } @Composable internal expect fun FreadPlatformTheme(darkTheme: Boolean) private fun ColorScheme.toColors(isLight: Boolean): Colors = Colors( primary = primary, primaryVariant = secondary, secondary = tertiary, secondaryVariant = tertiary, background = background, surface = surface, error = error, onPrimary = onPrimary, onSecondary = onSecondary, onBackground = onBackground, onSurface = onSurface, onError = onError, isLight = isLight, ) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/blur/BlurController.kt ================================================ package com.zhangke.framework.blur import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.rememberHazeState @Stable class BlurController private constructor( initEnabled: Boolean = true, ) { var enabled by mutableStateOf(initEnabled) var hazeState: HazeState? by mutableStateOf(null) companion object { fun create(): BlurController { return BlurController(true) } } } @Composable fun rememberBlurController(): BlurController { val controller = remember { BlurController.create() } val enableBlurAppBarStyle = LocalEnableBlurAppBarStyle.current LaunchedEffect(controller, enableBlurAppBarStyle) { controller.enabled = enableBlurAppBarStyle } return controller } val LocalBlurController = compositionLocalOf { null } @Composable fun Modifier.applyBlurSource(enabled: Boolean = true): Modifier { if (!enabled) return this val controller = LocalBlurController.current if (controller == null || !controller.enabled) return this val state = rememberHazeState(blurEnabled = true) DisposableEffect(enabled, controller.hazeState) { controller.hazeState = state onDispose { if (controller.hazeState == state) { controller.hazeState = null } } } return this.then(Modifier.hazeSource(state)) } @Composable fun Modifier.applyBlurEffect( enabled: Boolean = true, containerColor: Color = MaterialTheme.colorScheme.surface, ): Modifier { if (!enabled) return this val controller = LocalBlurController.current if (controller == null || !controller.enabled) return this val state = controller.hazeState ?: return this return this.hazeEffect( state = state, style = HazeMaterials.ultraThick(containerColor) ) } @Composable fun blurEffectContainerColor( enabled: Boolean = true, containerColor: Color = MaterialTheme.colorScheme.surface, ): Color { val enableContainerColor = enableContainerColor(enabled) return if (enableContainerColor) containerColor else Color.Transparent } @Composable private fun enableContainerColor(enabled: Boolean = true): Boolean { if (!enabled) return true val controller = LocalBlurController.current if (controller == null || !controller.enabled) return true controller.hazeState ?: return true return false } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/blur/LocalEnableBlurAppBarStyle.kt ================================================ package com.zhangke.framework.blur import androidx.compose.runtime.compositionLocalOf val LocalEnableBlurAppBarStyle = compositionLocalOf { true } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/blurhash/BitmapExt.kt ================================================ package com.zhangke.framework.blurhash import androidx.compose.ui.graphics.ImageBitmap expect fun bitmapFromBuffer(buffer: IntArray, width: Int, height: Int): ImageBitmap ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/blurhash/BlurHashDecoder.kt ================================================ package com.zhangke.framework.blurhash import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toArgb import kotlin.math.PI import kotlin.math.cos import kotlin.math.pow import kotlin.math.withSign /** * Copy from [BlurHashDecoder](https://github.com/woltapp/blurhash/blob/master/Kotlin/lib/src/main/java/com/wolt/blurhashkt/BlurHashDecoder.kt) */ object BlurHashDecoder { // cache Math.cos() calculations to improve performance. // The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps // the cache is enabled by default, it is recommended to disable it only when just a few images are displayed private val cacheCosinesX = mutableMapOf() private val cacheCosinesY = mutableMapOf() /** * Clear calculations stored in memory cache. * The cache is not big, but will increase when many image sizes are used, * if the app needs memory it is recommended to clear it. */ fun clearCache() { cacheCosinesX.clear() cacheCosinesY.clear() } /** * Decode a blur hash into a new bitmap. * * @param useCache use in memory cache for the calculated math, reused by images with same size. * if the cache does not exist yet it will be created and populated with new calculations. * By default it is true. */ fun decode( blurHash: String, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true, ): ImageBitmap? { if (blurHash.length < 6) { return null } if (width <= 0 || height <= 0) return null val numCompEnc = decode83(blurHash, 0, 1) val numCompX = (numCompEnc % 9) + 1 val numCompY = (numCompEnc / 9) + 1 if (blurHash.length != 4 + 2 * numCompX * numCompY) { return null } val maxAcEnc = decode83(blurHash, 1, 2) val maxAc = (maxAcEnc + 1) / 166f val colors = Array(numCompX * numCompY) { i -> if (i == 0) { val colorEnc = decode83(blurHash, 2, 6) decodeDc(colorEnc) } else { val from = 4 + i * 2 val colorEnc = decode83(blurHash, from, from + 2) decodeAc(colorEnc, maxAc * punch) } } return composeImageBitmap(width, height, numCompX, numCompY, colors, useCache) } private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int { var result = 0 for (i in from until to) { val index = charMap[str[i]] ?: -1 if (index != -1) { result = result * 83 + index } } return result } private fun decodeDc(colorEnc: Int): FloatArray { val r = colorEnc shr 16 val g = (colorEnc shr 8) and 255 val b = colorEnc and 255 return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) } private fun srgbToLinear(colorEnc: Int): Float { val v = colorEnc / 255f return if (v <= 0.04045f) { (v / 12.92f) } else { ((v + 0.055f) / 1.055f).pow(2.4f) } } private fun decodeAc(value: Int, maxAc: Float): FloatArray { val r = value / (19 * 19) val g = (value / 19) % 19 val b = value % 19 return floatArrayOf( signedPow2((r - 9) / 9.0f) * maxAc, signedPow2((g - 9) / 9.0f) * maxAc, signedPow2((b - 9) / 9.0f) * maxAc ) } private fun signedPow2(value: Float) = value.pow(2f).withSign(value) private fun composeImageBitmap( width: Int, height: Int, numCompX: Int, numCompY: Int, colors: Array, useCache: Boolean, ): ImageBitmap { // use an array for better performance when writing pixel colors val imageArray = IntArray(width * height) val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX) val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX) val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY) val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY) for (y in 0 until height) { for (x in 0 until width) { var r = 0f var g = 0f var b = 0f for (j in 0 until numCompY) { for (i in 0 until numCompX) { val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width) val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height) val basis = (cosX * cosY).toFloat() val color = colors[j * numCompX + i] r += color[0] * basis g += color[1] * basis b += color[2] * basis } } imageArray[x + width * y] = Color( red = linearToSrgb(r), green = linearToSrgb(g), blue = linearToSrgb(b) ).toArgb() } } return bitmapFromBuffer(imageArray, width, height) } private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when { calculate -> { DoubleArray(height * numCompY).also { cacheCosinesY.put(height * numCompY, it) } } else -> { cacheCosinesY[height * numCompY]!! } } private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when { calculate -> { DoubleArray(width * numCompX).also { cacheCosinesX.put(width * numCompX, it) } } else -> cacheCosinesX[width * numCompX]!! } private fun DoubleArray.getCos( calculate: Boolean, x: Int, numComp: Int, y: Int, size: Int, ): Double { if (calculate) { this[x + numComp * y] = cos(PI * y * x / size) } return this[x + numComp * y] } private fun linearToSrgb(value: Float): Int { val v = value.coerceIn(0f, 1f) return if (v <= 0.0031308f) { (v * 12.92f * 255f + 0.5f).toInt() } else { ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() } } private val charMap = listOf( '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',', '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' ) .mapIndexed { i, c -> c to i } .toMap() } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/blurhash/BlurHashModifier.kt ================================================ package com.zhangke.framework.blurhash import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.toSize import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.math.roundToInt fun Modifier.blurhash(blurHash: String?): Modifier = composed { if (blurHash.isNullOrEmpty()) { val placeholderColor = MaterialTheme.colorScheme.surfaceDim return@composed this.drawBehind { drawRect(color = placeholderColor) } } var size: Size? by remember(blurHash) { mutableStateOf(null) } var bitmap: ImageBitmap? by remember(blurHash) { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() if (size != null) { DisposableEffect(blurHash) { if (bitmap == null && size != null) { coroutineScope.launch { bitmap = withContext(Dispatchers.IO) { BlurHashDecoder.decode( blurHash = blurHash, width = (size!!.width * 0.5F).roundToInt(), height = (size!!.height * 0.5F).roundToInt(), useCache = false, ) } } } onDispose { // TODO: check is this bitmap need to recycler and earlier recycle bitmap = null } } } onSizeChanged { size = it.toSize() }.drawBehind { if (bitmap != null && size != null) { drawImage( image = bitmap!!, dstSize = IntSize(size!!.width.roundToInt(), size!!.height.roundToInt()), ) } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/collections/Collections.kt ================================================ package com.zhangke.framework.collections inline fun Iterable.mapFirst(transform: (T) -> R?): R { return mapFirstOrNull(transform) ?: throw NoSuchElementException() } inline fun Iterable.mapFirstOrNull(transform: (T) -> R?): R? { forEach { val v = transform(it) if (v != null) { return v } } return null } inline fun Iterable.container(predicate: (T) -> Boolean): Boolean { return firstOrNull(predicate) != null } inline fun Iterable.remove(predicate: (T) -> Boolean): List { return filter { !predicate(it) } } fun Iterable.removeIndex(index: Int): List { return filterIndexed { i, _ -> i != index } } fun List.updateItem(item: T, predicate: (T) -> T): List { return map { if (it == item) { predicate(it) } else { it } } } inline fun Iterable.updateIndex(index: Int, predicate: (T) -> T): List { return mapIndexed { i, t -> if (i == index) { predicate(t) } else { t } } } fun MutableIterable.removeFirstOrNull(block: (T) -> Boolean): T? { val iterator = iterator() while (iterator.hasNext()){ val item = iterator.next() if (block(item)) { iterator.remove() return item } } return null } fun Collection.getOrNull(block: (T) -> Boolean): T? { for (item in this) { if (block(item)) { return item } } return null } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/AlertConfirmDialog.kt ================================================ package com.zhangke.framework.composable import androidx.compose.runtime.Composable import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.stringResource @Composable fun AlertConfirmDialog( content: String, onConfirm: () -> Unit, onDismissRequest: () -> Unit, ) { FreadDialog( onDismissRequest = onDismissRequest, title = stringResource(LocalizedString.alert), contentText = content, positiveButtonText = stringResource(LocalizedString.ok), onPositiveClick = { onDismissRequest() onConfirm() }, negativeButtonText = stringResource(LocalizedString.cancel), onNegativeClick = onDismissRequest, ) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/AvatarStatck.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable 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.unit.Dp import androidx.compose.ui.unit.dp import com.seiko.imageloader.ui.AutoSizeImage @Composable fun AvatarHorizontalStack( modifier: Modifier = Modifier, avatars: List, avatarSize: Dp = 24.dp, borderColor: Color = Color.White, ) { Box( modifier = modifier, ) { avatars.forEachIndexed { index, imageUrl -> val startPadding = (avatarSize * index - avatarSize * 0.2F).coerceAtLeast(0.dp) AutoSizeImage( imageUrl, modifier = Modifier .padding(start = startPadding) .size(avatarSize) .border(color = borderColor, width = 1.dp, shape = CircleShape) .clip(CircleShape), contentScale = ContentScale.Crop, contentDescription = "avatar", ) } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/BackHandler.kt ================================================ package com.zhangke.framework.composable import androidx.compose.runtime.Composable import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState @Composable fun BackHandler(enabled: Boolean, block: () -> Unit) { val navState = rememberNavigationEventState(NavigationEventInfo.None) NavigationBackHandler( state = navState, isBackEnabled = enabled, onBackCancelled = {}, onBackCompleted = block, ) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/BezierCurve.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.Canvas 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.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.IntSize @Composable fun BezierCurve( modifier: Modifier, points: List, minPoint: Float? = null, maxPoint: Float? = null, style: BezierCurveStyle, ) { var size by remember { mutableStateOf(IntSize.Zero) } Canvas( modifier = modifier.onSizeChanged { size = it }, onDraw = { if (size != IntSize.Zero && points.size > 1) { drawBezierCurve( size = size, points = points, fixedMinPoint = minPoint, fixedMaxPoint = maxPoint, style = style, ) } }, ) } private fun DrawScope.drawBezierCurve( size: IntSize, points: List, fixedMinPoint: Float? = null, fixedMaxPoint: Float? = null, style: BezierCurveStyle, ) { val maxPoint = fixedMaxPoint ?: points.max() val minPoint = fixedMinPoint ?: points.min() val total = maxPoint - minPoint val height = size.height val width = size.width val xSpacing = width / (points.size - 1F) var lastPoint: Offset? = null val path = Path() var firstPoint = Offset(0F, 0F) for (index in points.indices) { val x = index * xSpacing val y = height - height * ((points[index] - minPoint) / total) if (lastPoint != null) { buildCurveLine(path, lastPoint, Offset(x, y)) } lastPoint = Offset(x, y) if (index == 0) { path.moveTo(x, y) firstPoint = Offset(x, y) } } fun closeWithBottomLine() { path.lineTo(width.toFloat(), height.toFloat()) path.lineTo(0F, height.toFloat()) path.lineTo(firstPoint.x, firstPoint.y) } when (style) { is BezierCurveStyle.Fill -> { closeWithBottomLine() drawPath( path = path, style = Fill, brush = style.brush, ) } is BezierCurveStyle.CurveStroke -> { drawPath( path = path, brush = style.brush, style = style.stroke, ) } is BezierCurveStyle.StrokeAndFill -> { drawPath( path = path, brush = style.strokeBrush, style = style.stroke, ) closeWithBottomLine() drawPath( path = path, brush = style.fillBrush, style = Fill, ) } } } private fun buildCurveLine(path: Path, startPoint: Offset, endPoint: Offset) { val firstControlPoint = Offset( x = startPoint.x + (endPoint.x - startPoint.x) / 2F, y = startPoint.y, ) val secondControlPoint = Offset( x = startPoint.x + (endPoint.x - startPoint.x) / 2F, y = endPoint.y, ) path.cubicTo( x1 = firstControlPoint.x, y1 = firstControlPoint.y, x2 = secondControlPoint.x, y2 = secondControlPoint.y, x3 = endPoint.x, y3 = endPoint.y, ) } sealed class BezierCurveStyle { class Fill(val brush: Brush) : BezierCurveStyle() class CurveStroke( val brush: Brush, val stroke: Stroke, ) : BezierCurveStyle() class StrokeAndFill( val fillBrush: Brush, val strokeBrush: Brush, val stroke: Stroke, ) : BezierCurveStyle() } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/BottomSheetState.kt ================================================ package com.zhangke.framework.composable import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp private val ModalBottomSheetPositionalThreshold = 56.dp private val ModalBottomSheetVelocityThreshold = 125.dp @OptIn(ExperimentalMaterial3Api::class) @Composable fun rememberTransientModalBottomSheetState( skipPartiallyExpanded: Boolean = false, confirmValueChange: (SheetValue) -> Boolean = { true }, initialValue: SheetValue = SheetValue.Hidden, skipHiddenState: Boolean = false, ): SheetState { val density = LocalDensity.current return remember( skipPartiallyExpanded, confirmValueChange, initialValue, skipHiddenState, density, ) { // Avoid rememberSaveable here so SheetValue is never serialized into saved state. SheetState( skipPartiallyExpanded = skipPartiallyExpanded, positionalThreshold = { with(density) { ModalBottomSheetPositionalThreshold.toPx() } }, velocityThreshold = { with(density) { ModalBottomSheetVelocityThreshold.toPx() } }, initialValue = initialValue, confirmValueChange = confirmValueChange, skipHiddenState = skipHiddenState, ) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/Bounds.kt ================================================ package com.zhangke.framework.composable import androidx.compose.ui.geometry.Offset data class Bounds( val left: Float, val top: Float, val right: Float, val bottom: Float, ) { val isEmpty = (right - left) * (bottom - top) == 0F fun inside(x: Float, y: Float): Boolean { return xInside(x) && yInside(y) } fun outside(x: Float, y: Float) = !inside(x, y) fun inside(offset: Offset) = inside(x = offset.x, y = offset.y) fun outside(offset: Offset) = outside(x = offset.x, y = offset.y) fun xInside(x: Float) = x in left..right fun yInside(y: Float) = y in top..bottom fun xOutside(x: Float) = !xInside(x) fun yOutside(y: Float) = !yInside(y) fun outsideAbsolute(offset: Offset) = xOutside(offset.x) && yOutside(offset.y) fun coerceInY(y: Float): Float { return y.coerceAtLeast(top).coerceAtMost(bottom) } fun coerceInX(x: Float): Float{ return x.coerceAtLeast(left).coerceAtMost(right) } fun coerceIn(offset: Offset): Offset { return Offset( x = offset.x.coerceIn(left..right), y = offset.y.coerceIn(top..bottom), ) } companion object { val EMPTY = Bounds(0F, 0F, 0F, 0F) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/CollapsableTopBarScrollConnection.kt ================================================ package com.zhangke.framework.composable import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource @Composable fun rememberCollapsableTopBarScrollConnection( maxPx: Float, minPx: Float, ): CollapsableTopBarScrollConnection { return rememberSaveable(minPx, maxPx, saver = CollapsableTopBarScrollConnection.Saver) { CollapsableTopBarScrollConnection(maxPx, minPx) } } class CollapsableTopBarScrollConnection( private val maxPx: Float, private val minPx: Float, ) : NestedScrollConnection { private var topBarHeight: Float = maxPx set(value) { field = value progress = 1 - (topBarHeight - minPx) / (maxPx - minPx) } var progress: Float by mutableFloatStateOf(0F) private set override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset { return handleScroll(available) } override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { return handleScroll(available) } private fun handleScroll(available: Offset): Offset { if (available.y <= 0 && topBarHeight <= minPx) return Offset.Zero if (available.y >= 0 && topBarHeight >= maxPx) return Offset.Zero val height = topBarHeight if (height == minPx) { if (available.y > 0F) { topBarHeight += available.y return Offset(0F, available.y) } } if (height + available.y > maxPx) { topBarHeight = maxPx return Offset(0f, maxPx - height) } if (height + available.y < minPx) { topBarHeight = minPx return Offset(0f, minPx - height) } topBarHeight += available.y return Offset.Zero } companion object { val Saver: Saver = listSaver( save = { listOf(it.maxPx, it.minPx, it.topBarHeight) }, restore = { CollapsableTopBarScrollConnection( maxPx = it[0], minPx = it[1], ).apply { topBarHeight = it[2] } }, ) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/CompositionLocal.kt ================================================ package com.zhangke.framework.composable import androidx.compose.runtime.Composable import androidx.compose.runtime.ProvidableCompositionLocal val ProvidableCompositionLocal.currentOrThrow: T @Composable get() = current ?: error("CompositionLocal is null") ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/DatePickerDialog.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDefaults import androidx.compose.material3.DatePickerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SelectableDates import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.zhangke.fread.localization.LocalizedString import io.ktor.util.date.getTimeMillis import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.stringResource import kotlin.time.Clock import kotlin.time.ExperimentalTime @OptIn(ExperimentalMaterial3Api::class) @Composable fun DatePickerDialog( datePickerState: DatePickerState, visible: Boolean, onDismissRequest: () -> Unit, onConfirmClick: () -> Unit, ) { if (!visible) return val bottomSheetState = rememberTransientModalBottomSheetState(skipPartiallyExpanded = true) val coroutineScope = rememberCoroutineScope() ModalBottomSheet( sheetState = bottomSheetState, onDismissRequest = { coroutineScope.launch { bottomSheetState.hide() onDismissRequest() } }, dragHandle = null, ) { Column( modifier = Modifier ) { Row( modifier = Modifier .padding(top = 16.dp, end = 16.dp) .fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { TextButton(onClick = { onDismissRequest() onConfirmClick() }) { Text(text = stringResource(LocalizedString.ok)) } } DatePicker( state = datePickerState, showModeToggle = false, colors = DatePickerDefaults.colors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow, ) ) } } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class) @Composable fun rememberFutureDatePickerState( initialSelectedDateMillis: Long? = null, ): DatePickerState { val currentYear: Int = remember { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).year } return rememberDatePickerState( initialSelectedDateMillis = initialSelectedDateMillis, yearRange = currentYear..2100, selectableDates = remember { FutureDates() }, ) } @OptIn(ExperimentalMaterial3Api::class) class FutureDates : SelectableDates { override fun isSelectableDate(utcTimeMillis: Long): Boolean { return getTimeMillis() < utcTimeMillis } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/DirectionalLazyListState.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @Composable fun rememberDirectionalLazyListState( lazyListState: LazyListState, ): DirectionalLazyListState { return remember(lazyListState) { DirectionalLazyListState(lazyListState) } } enum class ScrollDirection { Up, Down, None } class DirectionalLazyListState( private val lazyListState: LazyListState ) { private var positionY = lazyListState.firstVisibleItemScrollOffset private var visibleItem = lazyListState.firstVisibleItemIndex val scrollDirection by derivedStateOf { if (lazyListState.isScrollInProgress.not()) { ScrollDirection.None } else { val firstVisibleItemIndex = lazyListState.firstVisibleItemIndex val firstVisibleItemScrollOffset = lazyListState.firstVisibleItemScrollOffset // We are scrolling while first visible item hasn't changed yet if (firstVisibleItemIndex == visibleItem) { val direction = if (firstVisibleItemScrollOffset > positionY) { ScrollDirection.Down } else { ScrollDirection.Up } positionY = firstVisibleItemScrollOffset direction } else { val direction = if (firstVisibleItemIndex > visibleItem) { ScrollDirection.Down } else { ScrollDirection.Up } positionY = firstVisibleItemScrollOffset visibleItem = firstVisibleItemIndex direction } } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/DpSaver.kt ================================================ package com.zhangke.framework.composable import androidx.compose.runtime.saveable.Saver import androidx.compose.ui.unit.Dp object DpSaver { val Saver: Saver = Saver( save = { it.value }, restore = { Dp(it) } ) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/DurationSelector.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.sd.lib.compose.wheel_picker.FVerticalWheelPicker import com.sd.lib.compose.wheel_picker.FWheelPickerState import com.sd.lib.compose.wheel_picker.rememberFWheelPickerState import com.zhangke.framework.utils.format import com.zhangke.fread.localization.LocalizedString import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import kotlin.time.Duration import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes @Composable fun DurationSelector( defaultDuration: Duration, onDismissRequest: () -> Unit, onDurationSelect: (Duration) -> Unit, ) { var currentDuration by remember { mutableStateOf(defaultDuration) } FreadDialog( onDismissRequest = onDismissRequest, title = stringResource(LocalizedString.durationSelectorTitle), onNegativeClick = { onDismissRequest() }, onPositiveClick = { onDismissRequest() onDurationSelect(currentDuration) }, content = { DurationSelectorContent( defaultDuration = defaultDuration, onDurationChanged = { currentDuration = it } ) }, ) } @Composable private fun DurationSelectorContent( defaultDuration: Duration, onDurationChanged: (Duration) -> Unit, ) { val dayList = remember { mutableListOf(0, 1, 2, 3, 4, 5, 6, 7) } val dayState = rememberFWheelPickerState(0) val hourList = remember { mutableListOf().apply { repeat(24) { add(it) } } } val hourState = rememberFWheelPickerState(0) val minutesList = remember { mutableListOf().apply { repeat(60) { add(it) } } } val minutesState = rememberFWheelPickerState(0) val currentDay = dayState.currentIndex val currentHour = hourState.currentIndex val currentMinutes = minutesState.currentIndex LaunchedEffect(currentDay, currentHour, currentMinutes) { if (currentDay < 0 || currentHour < 0 || currentMinutes < 0) return@LaunchedEffect val currentDuration = dayList[currentDay].days + hourList[currentHour].hours + minutesList[currentMinutes].minutes onDurationChanged(currentDuration) } LaunchedEffect(defaultDuration) { launch { val formatted = defaultDuration.format() dayList.indexOf(formatted.days).takeIf { it >= 0 }?.let { dayState.animateScrollToIndex(it) } hourList.indexOf(formatted.hours).takeIf { it >= 0 }?.let { hourState.animateScrollToIndex(it) } minutesList.indexOf(formatted.minutes).takeIf { it >= 0 }?.let { minutesState.animateScrollToIndex(it) } } } Row( modifier = Modifier .padding(16.dp) .fillMaxWidth() ) { Column(modifier = Modifier.weight(1F)) { Text( modifier = Modifier.align(Alignment.CenterHorizontally), text = stringResource(LocalizedString.durationDay), ) DurationSelectorItem( state = dayState, modifier = Modifier.fillMaxWidth(), list = dayList, ) } Box(modifier = Modifier.width(8.dp)) Column(modifier = Modifier.weight(1F)) { Text( modifier = Modifier.align(Alignment.CenterHorizontally), text = stringResource(LocalizedString.durationHour), ) DurationSelectorItem( state = hourState, modifier = Modifier.fillMaxWidth(), list = hourList, ) } Box(modifier = Modifier.width(8.dp)) Column(modifier = Modifier.weight(1F)) { Text( modifier = Modifier.align(Alignment.CenterHorizontally), text = stringResource(LocalizedString.durationMinute), ) DurationSelectorItem( state = minutesState, modifier = Modifier.fillMaxWidth(), list = minutesList, ) } } } @Composable private fun DurationSelectorItem( state: FWheelPickerState, modifier: Modifier = Modifier, list: List, ) { FVerticalWheelPicker( modifier = modifier, count = list.size, state = state, ) { index -> Text(text = list[index].toString()) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/FlowUtils.android.kt ================================================ package com.zhangke.framework.composable import kotlinx.coroutines.flow.MutableSharedFlow suspend fun MutableSharedFlow.tryEmitException(exception: Throwable) { exception.message ?.takeIf { it.isNotEmpty() } ?.let { textOf(it) } ?.let { this.emit(it) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/FlowUtils.kt ================================================ package com.zhangke.framework.composable import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.lifecycle.ViewModel import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.lifecycle.SubViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @Composable fun ConsumeFlow( flow: Flow, block: suspend (T) -> Unit ) { val updatedBlock by rememberUpdatedState(block) LaunchedEffect(flow) { flow.collect { updatedBlock(it) } } } context(viewModel: ViewModel) fun MutableSharedFlow.emitInViewModel(element: T) { viewModel.launchInViewModel { emit(element) } } context(viewModel: SubViewModel) fun MutableSharedFlow.emitInViewModel(element: T) { viewModel.launchInViewModel { emit(element) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/FreadDialog.kt ================================================ package com.zhangke.framework.composable 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.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material3.Card import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.zhangke.framework.ktx.ifNullOrEmpty import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.stringResource @Composable fun FreadDialog( onDismissRequest: () -> Unit, properties: DialogProperties = DialogProperties(), title: String? = null, contentText: String, negativeButtonText: String? = null, positiveButtonText: String? = null, onNegativeClick: (() -> Unit)? = null, onPositiveClick: (() -> Unit)? = null, ) { FreadDialog( onDismissRequest = onDismissRequest, properties = properties, title = title, content = { Text(text = contentText) }, negativeButtonText = negativeButtonText, positiveButtonText = positiveButtonText, onNegativeClick = onNegativeClick, onPositiveClick = onPositiveClick, ) } @Composable fun FreadDialog( onDismissRequest: () -> Unit, properties: DialogProperties = DialogProperties(), title: String? = null, content: (@Composable () -> Unit)? = null, negativeButtonText: String? = null, positiveButtonText: String? = null, onNegativeClick: (() -> Unit)? = null, onPositiveClick: (() -> Unit)? = null, ) { FreadDialog( onDismissRequest = onDismissRequest, properties = properties, header = { Text( text = title.ifNullOrEmpty { stringResource(LocalizedString.alert) }, ) }, content = content, negativeButton = if (negativeButtonText.isNullOrEmpty() && onNegativeClick == null) { null } else { { TextButton(onClick = { onNegativeClick?.invoke() }) { Text(text = negativeButtonText.ifNullOrEmpty { stringResource(LocalizedString.cancel) }) } } }, positiveButton = if (positiveButtonText.isNullOrEmpty() && onPositiveClick == null) { null } else { { TextButton(onClick = { onPositiveClick?.invoke() }) { Text(text = positiveButtonText.ifNullOrEmpty { stringResource(LocalizedString.ok) }) } } } ) } @Composable fun FreadDialog( onDismissRequest: () -> Unit, properties: DialogProperties = DialogProperties(), header: (@Composable () -> Unit)? = null, content: (@Composable () -> Unit)? = null, titleContentColor: Color = MaterialTheme.colorScheme.onSurface, textContentColor: Color = MaterialTheme.colorScheme.onSurface, negativeButton: (@Composable () -> Unit)? = null, positiveButton: (@Composable () -> Unit)? = null, ) { Dialog( onDismissRequest = onDismissRequest, properties = properties, ) { Card( modifier = Modifier .fillMaxWidth() .wrapContentHeight(), shape = MaterialTheme.shapes.extraLarge, ) { Column( modifier = Modifier .fillMaxWidth() .wrapContentHeight() .padding(start = 24.dp, top = 16.dp, end = 20.dp, bottom = 8.dp) ) { if (header != null) { ProvideContentColorTextStyle( contentColor = titleContentColor, textStyle = MaterialTheme.typography.titleMedium .copy( fontSize = 18.sp, fontWeight = FontWeight.SemiBold, ), ) { Box( Modifier .align(Alignment.CenterHorizontally) .fillMaxWidth(), contentAlignment = Alignment.CenterStart, ) { header() } } } if (content != null) { val textStyle = MaterialTheme.typography.bodyMedium ProvideContentColorTextStyle( contentColor = textContentColor, textStyle = textStyle ) { Box( Modifier .fillMaxWidth() .padding(top = 12.dp) .align(Alignment.Start), contentAlignment = Alignment.CenterStart, ) { content() } } } Row( modifier = Modifier .fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { negativeButton?.invoke() if (positiveButton != null) { Spacer(modifier = Modifier.size(width = 8.dp, height = 1.dp)) positiveButton() } } } } } } @Composable private fun ProvideContentColorTextStyle( contentColor: Color, textStyle: TextStyle, content: @Composable () -> Unit ) { val mergedStyle = LocalTextStyle.current.merge(textStyle) CompositionLocalProvider( LocalContentColor provides contentColor, LocalTextStyle provides mergedStyle, content = content ) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/FreadTabRow.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.background 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab import androidx.compose.material3.TabPosition import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.TabRowDefaults.primaryContainerColor import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf 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.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.zhangke.framework.blur.applyBlurEffect import com.zhangke.framework.blur.blurEffectContainerColor import com.zhangke.framework.utils.pxToDp import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi @OptIn(ExperimentalHazeMaterialsApi::class) @Composable fun FreadTabRow( selectedTabIndex: Int, modifier: Modifier = Modifier, containerColor: Color = primaryContainerColor, blurEffectEnabled: Boolean = true, indicatorContent: @Composable (tabPosition: TabPosition) -> Unit = @Composable { FreadTabRowDefault.Indicator() }, divider: @Composable () -> Unit = @Composable { HorizontalDivider(thickness = 0.5.dp) }, tabCount: Int, tabContent: @Composable (index: Int) -> Unit, onTabClick: (index: Int) -> Unit, ) { val density = LocalDensity.current val tabContentWidth = remember { mutableStateMapOf() } FreadTabRow( modifier = modifier.applyBlurEffect( enabled = blurEffectEnabled, containerColor = containerColor ), selectedTabIndex = selectedTabIndex, containerColor = blurEffectContainerColor( enabled = blurEffectEnabled, containerColor = containerColor ), indicator = { tabPositions -> Box( modifier = Modifier.fillMaxSize().padding(bottom = 1.5.dp), contentAlignment = Alignment.BottomCenter, ) { divider() } val position = tabPositions.getOrNull(selectedTabIndex) if (position != null) { Column( modifier = Modifier .ownTabIndicatorOffset( currentTabPosition = position, currentTabWidth = tabContentWidth[selectedTabIndex] ?: 0.dp, ), horizontalAlignment = Alignment.CenterHorizontally, ) { indicatorContent(position) Box(modifier = Modifier.height(1.5.dp)) } } }, divider = {}, tabs = { repeat(tabCount) { index -> Tab( selected = index == selectedTabIndex, onClick = { onTabClick(index) }, ) { Box( modifier = Modifier .onSizeChanged { tabContentWidth[index] = it.width.pxToDp(density) } .padding(8.dp) ) { val contentColor = if (index == selectedTabIndex) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurface } CompositionLocalProvider( LocalContentColor provides contentColor ) { tabContent(index) } } } } }, ) } @Composable private fun FreadTabRow( selectedTabIndex: Int, modifier: Modifier = Modifier, containerColor: Color = primaryContainerColor, indicator: @Composable (tabPositions: List) -> Unit = @Composable { tabPositions -> TabRowDefaults.Indicator( Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]) ) }, divider: @Composable () -> Unit = @Composable { HorizontalDivider() }, tabs: @Composable () -> Unit ) { val density = LocalDensity.current var tabContainerWidth: Dp? by rememberSaveable(saver = StateSaver.MutableNullableDpSaver) { mutableStateOf(null) } var allTabSumWidth: Dp? by rememberSaveable(saver = StateSaver.MutableNullableDpSaver) { mutableStateOf(null) } var tabEdgePadding by rememberSaveable(saver = StateSaver.MutableDpSaver) { mutableStateOf(0.dp) } if (tabContainerWidth != null && allTabSumWidth != null) { LaunchedEffect(tabContainerWidth, allTabSumWidth) { tabEdgePadding = if (tabContainerWidth!! <= allTabSumWidth!!) { 0.dp } else { (tabContainerWidth!! - allTabSumWidth!!) / 2 } } } ScrollableTabRow( selectedTabIndex = selectedTabIndex, modifier = modifier.onSizeChanged { tabContainerWidth = it.width.pxToDp(density) }, containerColor = containerColor, edgePadding = tabEdgePadding, indicator = { tabPositions -> var allWidth = 0.dp for (position in tabPositions) { allWidth += position.width } allTabSumWidth = allWidth indicator(tabPositions) }, divider = divider, tabs = tabs, ) } object FreadTabRowDefault { private val IndicatorHeight = 3.dp @Composable fun Indicator( modifier: Modifier = Modifier, height: Dp = IndicatorHeight, color: Color = MaterialTheme.colorScheme.primary, shape: Shape = RoundedCornerShape( topStart = 3.dp, topEnd = 3.dp, ), ) { Box( modifier .fillMaxWidth() .height(height) .background(color = color, shape = shape) ) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/Grid.kt ================================================ package com.zhangke.framework.composable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlin.math.max @Composable fun Grid( modifier: Modifier, horizontalSpacing: Dp = 0.dp, verticalSpacing: Dp = 0.dp, columnCount: Int, content: @Composable () -> Unit, ) { Layout( modifier = modifier, content = content, ) { measurables, constraints -> val itemWidth = (constraints.maxWidth - horizontalSpacing.roundToPx() * (columnCount - 1)) / columnCount val rowCount = (measurables.size - 1) / columnCount + 1 val itemConstraints = constraints.copy(minWidth = itemWidth, maxWidth = itemWidth) val placeables = measurables.map { it.measure(itemConstraints) } val itemTotalHeight = placeables.chunked(columnCount).sumOf { list -> list.maxBy { it.height }.height } val totalHeight = (rowCount - 1) * verticalSpacing.roundToPx() + itemTotalHeight layout(constraints.maxWidth, totalHeight) { var yOffset = 0 var itemHeight = -1 placeables.forEachIndexed { index, placeable -> val xSpacing = (index % columnCount) * horizontalSpacing.roundToPx() placeable.placeRelative( x = itemWidth * (index % columnCount) + xSpacing, y = yOffset, ) itemHeight = max(itemHeight, placeable.height) if (index % columnCount == columnCount - 1) { // next round is new line yOffset += itemHeight yOffset += verticalSpacing.roundToPx() } } } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/HorizontalPageIndicator.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.Canvas import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @Composable fun HorizontalPageIndicator( currentIndex: Int, pageCount: Int, modifier: Modifier, style: IndicatorStyle = IndicatorStyle.default(), ) { Canvas(modifier = modifier) { val inactiveDiameter = style.inactiveDiameter.toPx() val inactiveRadius = inactiveDiameter / 2 val activeLineWidth = style.activeLineWidth.toPx() val activeColor = style.activeColor val inactiveColor = style.inactiveColor val indicatorSpacing = inactiveDiameter var totalWidth = 0f for (i in 0 until pageCount) { totalWidth += if (i == currentIndex) activeLineWidth else inactiveDiameter } totalWidth += (pageCount - 1) * indicatorSpacing val startX = (size.width - totalWidth) / 2 val startY = (size.height - inactiveDiameter) / 2 var currentX = startX for (i in 0 until pageCount) { if (i == currentIndex) { drawRoundRect( color = activeColor, topLeft = Offset(currentX, startY), size = Size(activeLineWidth, inactiveDiameter), cornerRadius = CornerRadius(inactiveRadius, inactiveRadius), ) currentX += activeLineWidth + indicatorSpacing } else { drawCircle( color = inactiveColor, radius = inactiveRadius, center = Offset( x = currentX + inactiveRadius, y = startY + inactiveRadius ) ) currentX += inactiveDiameter + indicatorSpacing } } } } data class IndicatorStyle( val activeLineWidth: Dp, val inactiveDiameter: Dp, val activeColor: Color, val inactiveColor: Color, ) { companion object { @Composable fun default( activeLineWidth: Dp = 12.dp, inactiveDiameter: Dp = 4.dp, activeColor: Color = MaterialTheme.colorScheme.inverseOnSurface, inactiveColor: Color = MaterialTheme.colorScheme.inverseOnSurface, ): IndicatorStyle = IndicatorStyle( activeLineWidth = activeLineWidth, inactiveDiameter = inactiveDiameter, activeColor = activeColor, inactiveColor = inactiveColor, ) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/IconButton.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonColors import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Stable import androidx.compose.runtime.remember 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.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @Composable fun SimpleIconButton( onClick: () -> Unit, modifier: Modifier = Modifier, iconModifier: Modifier = Modifier, iconSize: Dp, enabled: Boolean = true, imageVector: ImageVector, contentDescription: String?, colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), tint: Color = LocalContentColor.current, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { Box( modifier = modifier .size(iconSize) .clip(CircleShape) .background(color = if (enabled) colors.containerColor else colors.disabledContainerColor) .clickable( enabled = enabled, role = Role.Button, interactionSource = interactionSource, indication = ripple(), onClick = onClick, ), contentAlignment = Alignment.Center ) { val contentColor = if (enabled) colors.contentColor else colors.disabledContentColor CompositionLocalProvider(LocalContentColor provides contentColor) { Icon( modifier = iconModifier, imageVector = imageVector, contentDescription = contentDescription, tint = tint, ) } } } @Composable fun SimpleIconButton( onClick: () -> Unit, modifier: Modifier = Modifier, iconModifier: Modifier = Modifier, enabled: Boolean = true, imageVector: ImageVector, contentDescription: String?, colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), tint: Color? = null, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { IconButton( onClick = onClick, modifier = modifier, enabled = enabled, colors = colors, interactionSource = interactionSource, ) { val icTint = tint ?: LocalContentColor.current Icon( modifier = iconModifier, imageVector = imageVector, contentDescription = contentDescription, tint = icTint, ) } } @Composable fun SimpleIconButton( onClick: () -> Unit, modifier: Modifier = Modifier, iconModifier: Modifier = Modifier, enabled: Boolean = true, painter: Painter, contentDescription: String?, colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), tint: Color? = null, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { IconButton( onClick = onClick, modifier = modifier, enabled = enabled, colors = colors, interactionSource = interactionSource, ) { val icTint = tint ?: LocalContentColor.current Icon( modifier = iconModifier, painter = painter, contentDescription = contentDescription, tint = icTint, ) } } @Composable fun SimpleIconButton( onClick: () -> Unit, onLongClick: () -> Unit, imageVector: ImageVector, modifier: Modifier = Modifier, iconModifier: Modifier = Modifier, enabled: Boolean = true, contentDescription: String?, tint: Color? = null, colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { Box( modifier = modifier .minimumInteractiveComponentSize() .size(40.dp) .clip(RoundedCornerShape(50)) .background(color = colors.containerColor(enabled), shape = RoundedCornerShape(50)) .combinedClickable( enabled = enabled, onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource, ), ) { val icTint = tint ?: LocalContentColor.current Icon( modifier = iconModifier.align(Alignment.Center), imageVector = imageVector, contentDescription = contentDescription, tint = icTint, ) } } @Stable private fun IconButtonColors.containerColor(enabled: Boolean): Color = if (enabled) containerColor else disabledContainerColor ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/InsetAwareSearchBar.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBarColors import androidx.compose.material3.SearchBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp @OptIn(ExperimentalMaterial3Api::class) @Composable fun InsetAwareSearchBar( expanded: Boolean, onExpandedChange: (Boolean) -> Unit, inputField: @Composable () -> Unit, modifier: Modifier = Modifier, windowInsets: WindowInsets = WindowInsets.statusBars, insetContainerColor: Color = MaterialTheme.colorScheme.surface, colors: SearchBarColors = SearchBarDefaults.colors(), tonalElevation: Dp = SearchBarDefaults.TonalElevation, shadowElevation: Dp = SearchBarDefaults.ShadowElevation, shape: Shape = SearchBarDefaults.inputFieldShape, content: @Composable ColumnScope.() -> Unit = {}, ) { Box( modifier = modifier .windowInsetsPadding(windowInsets) .background(insetContainerColor), ) { SearchBar( modifier = Modifier.fillMaxWidth(), windowInsets = WindowInsets(0, 0, 0, 0), expanded = expanded, onExpandedChange = onExpandedChange, inputField = inputField, colors = colors, tonalElevation = tonalElevation, shadowElevation = shadowElevation, shape = shape, content = content, ) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/Keyboard.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.ime import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.platform.LocalDensity @Composable fun keyboardAsState(): State { val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0 return rememberUpdatedState(isImeVisible) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/LazyListStateUtils.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @Composable fun canScrollBackward(state: LazyListState): Boolean { val canScrollBackward by remember { derivedStateOf { state.firstVisibleItemIndex != 0 || state.firstVisibleItemScrollOffset != 0 } } return canScrollBackward } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/LoadableLayout.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.sp import com.zhangke.fread.localization.LocalizedString import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update sealed class LoadableState { val isSuccess: Boolean get() = this is Success val isFailed: Boolean get() = this is Failed val isIdle: Boolean get() = this is Idle val isLoading: Boolean get() = this is Loading class Idle : LoadableState() class Failed(val exception: Throwable) : LoadableState() class Loading : LoadableState() class Success(val data: T) : LoadableState() companion object { fun idle(): LoadableState { return Idle() } fun success(data: T): LoadableState { return Success(data) } fun loading(): LoadableState { return Loading() } fun failed(exception: Throwable): LoadableState { return Failed(exception) } } } @Composable fun LoadableLayout( modifier: Modifier = Modifier, state: LoadableState, failed: (@Composable BoxScope.(Throwable) -> Unit)? = null, loading: (@Composable BoxScope.() -> Unit)? = null, idle: (@Composable BoxScope.() -> Unit)? = null, content: @Composable BoxScope.(T) -> Unit, ) { Box(modifier = modifier) { when (state) { is LoadableState.Loading -> { loading?.invoke(this) ?: DefaultLoading() } is LoadableState.Failed -> { failed?.invoke(this, state.exception) ?: DefaultFailed(exception = state.exception) } is LoadableState.Success -> { content(state.data) } is LoadableState.Idle -> { idle?.invoke(this) ?: DefaultIdle() } } } } @Composable fun BoxScope.DefaultLoading(modifier: Modifier = Modifier) { Box(modifier = modifier.fillMaxWidth(0.3F).align(Alignment.Center)) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } @Composable fun BoxScope.DefaultFailed( modifier: Modifier = Modifier, errorMessage: String, onRetryClick: (() -> Unit)? = null, ) { Column( modifier = modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( fontSize = 18.sp, text = errorMessage, ) if (onRetryClick != null) { Button( onClick = onRetryClick, ) { Text(org.jetbrains.compose.resources.stringResource(LocalizedString.retry)) } } } } @Composable fun BoxScope.DefaultFailed( modifier: Modifier = Modifier, exception: Throwable, onRetryClick: (() -> Unit)? = null, ) { DefaultFailed( modifier = modifier, errorMessage = exception.message.orEmpty(), onRetryClick = onRetryClick, ) } @Composable fun BoxScope.DefaultEmpty( modifier: Modifier = Modifier, message: String = org.jetbrains.compose.resources.stringResource(LocalizedString.empty), ) { Box( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text(text = message) } } @Composable fun BoxScope.DefaultIdle( modifier: Modifier = Modifier, ) { Box(modifier = modifier) } fun MutableStateFlow>.updateToSuccess( data: T ) { update { LoadableState.success(data) } } fun MutableStateFlow>.updateToLoading() { update { LoadableState.loading() } } fun MutableStateFlow>.updateToFailed(e: Throwable) { update { LoadableState.failed(e) } } fun MutableStateFlow>.updateOnSuccess( updater: (T) -> T, ) { update { if (it.isSuccess) { val data = it.requireSuccessData() LoadableState.success(updater(data)) } else { it } } } fun LoadableState.requireSuccessData(): T { return (this as LoadableState.Success).data } fun LoadableState.successDataOrNull(): T? { return (this as? LoadableState.Success)?.data } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/Loading.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.Box 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.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp private val lineHeight = 68.dp @Composable fun LoadingLineItem( modifier: Modifier = Modifier, ) { Box( modifier = modifier.height(lineHeight), contentAlignment = Alignment.Center, ) { CircularProgressIndicator( modifier = Modifier.size(32.dp) ) } } @Composable fun LoadErrorLineItem( modifier: Modifier, errorMessage: String, ) { Box( modifier = modifier.heightIn(min = lineHeight), contentAlignment = Alignment.Center, ) { Text( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 16.dp), textAlign = TextAlign.Center, text = errorMessage, maxLines = 3, overflow = TextOverflow.Ellipsis, ) } } @Composable fun LoadErrorLineItem( modifier: Modifier, errorMessage: TextString, ) { LoadErrorLineItem( modifier = modifier, errorMessage = textString(text = errorMessage), ) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/LoadingDialog.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Surface 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.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.stringResource @Composable fun CancelableLoadingDialog( loading: Boolean, onDismissRequest: () -> Unit, onCancelClick: () -> Unit, properties: DialogProperties = DialogProperties(), ) { if (loading) { Dialog( onDismissRequest = onDismissRequest, properties = properties, ) { Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), ) { Column( modifier = Modifier.fillMaxWidth() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(modifier = Modifier.height(16.dp)) CircularProgressIndicator( modifier = Modifier .padding(vertical = 24.dp, horizontal = 64.dp) .size(80.dp) ) Box(modifier = Modifier.fillMaxWidth()) { TextButton( modifier = Modifier.align(Alignment.CenterEnd), onClick = onCancelClick, ) { Text( text = stringResource(LocalizedString.cancel) ) } } } } } } } @Composable fun LoadingDialog( loading: Boolean, properties: DialogProperties = DialogProperties(), onDismissRequest: () -> Unit = {}, ) { if (loading) { Dialog( onDismissRequest = onDismissRequest, properties = properties, ) { Surface( modifier = Modifier, shape = RoundedCornerShape(16.dp), ) { CircularProgressIndicator( modifier = Modifier .padding(vertical = 24.dp, horizontal = 64.dp) .size(80.dp) ) } } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/LocalContentPadding.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp val LocalContentPadding = compositionLocalOf { PaddingValues(0.dp) } @Composable fun updateTopPadding(topPadding: Dp): PaddingValues { val paddings = LocalContentPadding.current val layoutDirection = LocalLayoutDirection.current return PaddingValues( start = paddings.calculateStartPadding(layoutDirection), top = topPadding, end = paddings.calculateEndPadding(layoutDirection), bottom = paddings.calculateBottomPadding(), ) } @Composable fun plusTopPadding(topPadding: Dp): PaddingValues { val paddings = LocalContentPadding.current val layoutDirection = LocalLayoutDirection.current return PaddingValues( start = paddings.calculateStartPadding(layoutDirection), top = topPadding + paddings.calculateTopPadding(), end = paddings.calculateEndPadding(layoutDirection), bottom = paddings.calculateBottomPadding(), ) } @Composable fun plusContentPadding(paddingValues: PaddingValues): PaddingValues { val localPaddingValues = LocalContentPadding.current val layoutDirection = LocalLayoutDirection.current return PaddingValues( start = localPaddingValues.calculateStartPadding(layoutDirection) + paddingValues.calculateStartPadding(layoutDirection), top = localPaddingValues.calculateTopPadding() + paddingValues.calculateTopPadding(), end = localPaddingValues.calculateEndPadding(layoutDirection) + paddingValues.calculateEndPadding(layoutDirection), bottom = localPaddingValues.calculateBottomPadding() + paddingValues.calculateBottomPadding(), ) } @Composable fun PaddingValues.plus(paddingValues: PaddingValues): PaddingValues { val layoutDirection = LocalLayoutDirection.current return PaddingValues( start = this.calculateStartPadding(layoutDirection) + paddingValues.calculateStartPadding(layoutDirection), top = this.calculateTopPadding() + paddingValues.calculateTopPadding(), end = this.calculateEndPadding(layoutDirection) + paddingValues.calculateEndPadding(layoutDirection), bottom = this.calculateBottomPadding() + paddingValues.calculateBottomPadding(), ) } fun Modifier.contentBottomPadding(): Modifier = composed { val paddings = LocalContentPadding.current this.padding(bottom = paddings.calculateBottomPadding()) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/LocalSnackMessage.kt ================================================ package com.zhangke.framework.composable import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.staticCompositionLocalOf val LocalSnackbarHostState: ProvidableCompositionLocal = staticCompositionLocalOf { null } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/NavigationBar.kt ================================================ package com.zhangke.framework.composable import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.ripple import androidx.compose.material3.ColorScheme import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBarDefaults import androidx.compose.material3.NavigationBarItemColors import androidx.compose.material3.Surface import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.layoutId import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.constrainHeight import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastFirst import androidx.compose.ui.util.fastFirstOrNull import com.zhangke.framework.blur.applyBlurEffect import com.zhangke.framework.blur.blurEffectContainerColor import kotlinx.coroutines.flow.map import kotlin.math.roundToInt @Composable fun NavigationBar( modifier: Modifier = Modifier, containerColor: Color = NavigationBarDefaults.containerColor, contentColor: Color = MaterialTheme.colorScheme.contentColorFor(containerColor), tonalElevation: Dp = NavigationBarDefaults.Elevation, windowInsets: WindowInsets = NavigationBarDefaults.windowInsets, content: @Composable RowScope.() -> Unit ) { Surface( color = blurEffectContainerColor(containerColor = containerColor), contentColor = contentColor, tonalElevation = tonalElevation, modifier = modifier.applyBlurEffect( containerColor = containerColor, ), ) { Row( modifier = Modifier .fillMaxWidth() .windowInsetsPadding(windowInsets) .padding(top = 16.dp, bottom = 16.dp) .selectableGroup(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, content = content, ) } } @Composable fun RowScope.NavigationBarItem( selected: Boolean, onClick: () -> Unit, icon: @Composable () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, label: @Composable (() -> Unit)? = null, alwaysShowLabel: Boolean = true, colors: NavigationBarItemColors = NavigationBarItemDefaults.colors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } ) { val styledIcon = @Composable { val iconColor by colors.iconColor(selected = selected, enabled = enabled) // If there's a label, don't have a11y services repeat the icon description. val clearSemantics = label != null && (alwaysShowLabel || selected) Box(modifier = if (clearSemantics) Modifier.clearAndSetSemantics {} else Modifier) { CompositionLocalProvider(LocalContentColor provides iconColor, content = icon) } } var itemWidth by remember { mutableIntStateOf(0) } Box( modifier .selectable( selected = selected, onClick = onClick, enabled = enabled, role = Role.Tab, interactionSource = interactionSource, indication = null, ) .weight(1f) .onSizeChanged { itemWidth = it.width }, contentAlignment = Alignment.Center, propagateMinConstraints = true, ) { val animationProgress: State = animateFloatAsState( targetValue = if (selected) 1f else 0f, animationSpec = tween(ItemAnimationDurationMillis), label = "navigation-bar", ) val deltaOffset: Offset with(LocalDensity.current) { val indicatorWidth = 64.dp.roundToPx() deltaOffset = Offset( (itemWidth - indicatorWidth).toFloat() / 2, IndicatorVerticalOffset.toPx() ) } val offsetInteractionSource = remember(interactionSource, deltaOffset) { MappedInteractionSource(interactionSource, deltaOffset) } val indicatorRipple = @Composable { @Suppress("DEPRECATION_ERROR") (Box( Modifier .layoutId(IndicatorRippleLayoutIdTag) .clip(CircleShape) .indication( offsetInteractionSource, ripple() ) )) } val indicator = @Composable { Box( Modifier .layoutId(IndicatorLayoutIdTag) .graphicsLayer { alpha = animationProgress.value } .background( color = colors.selectedIndicatorColor, shape = CircleShape, ) ) } NavigationBarItemLayout( indicatorRipple = indicatorRipple, indicator = indicator, icon = styledIcon, animationProgress = { animationProgress.value }, ) } } @Composable private fun NavigationBarItemColors.iconColor(selected: Boolean, enabled: Boolean): State { val targetValue = when { !enabled -> disabledIconColor selected -> selectedIconColor else -> unselectedIconColor } return animateColorAsState( targetValue = targetValue, animationSpec = tween(ItemAnimationDurationMillis), label = "BottomNavIconColorAnimation", ) } object NavigationBarItemDefaults { @Composable fun colors() = MaterialTheme.colorScheme.defaultNavigationBarItemColors @Composable fun colors( selectedIconColor: Color = Color.Unspecified, selectedTextColor: Color = Color.Unspecified, indicatorColor: Color = Color.Unspecified, unselectedIconColor: Color = Color.Unspecified, unselectedTextColor: Color = Color.Unspecified, disabledIconColor: Color = Color.Unspecified, disabledTextColor: Color = Color.Unspecified, ): NavigationBarItemColors = MaterialTheme.colorScheme.defaultNavigationBarItemColors.copy( selectedIconColor = selectedIconColor, selectedTextColor = selectedTextColor, selectedIndicatorColor = indicatorColor, unselectedIconColor = unselectedIconColor, unselectedTextColor = unselectedTextColor, disabledIconColor = disabledIconColor, disabledTextColor = disabledTextColor, ) private const val DisabledAlpha = 0.38f private val ColorScheme.defaultNavigationBarItemColors: NavigationBarItemColors @Composable get() { return NavigationBarItemColors( selectedIconColor = onSecondaryContainer, selectedTextColor = onSurface, selectedIndicatorColor = secondaryContainer, unselectedIconColor = onSurfaceVariant, unselectedTextColor = onSurfaceVariant, disabledIconColor = onSurfaceVariant.copy(alpha = DisabledAlpha), disabledTextColor = onSurfaceVariant.copy(alpha = DisabledAlpha), ) } } @Composable private fun NavigationBarItemLayout( indicatorRipple: @Composable () -> Unit, indicator: @Composable () -> Unit, icon: @Composable () -> Unit, animationProgress: () -> Float, ) { Layout( content = { indicatorRipple() indicator() Box(Modifier.layoutId(IconLayoutIdTag)) { icon() } }, measurePolicy = { measurables, constraints -> @Suppress("NAME_SHADOWING") val animationProgress = animationProgress() val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) val iconPlaceable = measurables.fastFirst { it.layoutId == IconLayoutIdTag }.measure(looseConstraints) val totalIndicatorWidth = iconPlaceable.width + (IndicatorHorizontalPadding * 2).roundToPx() val animatedIndicatorWidth = (totalIndicatorWidth * animationProgress).roundToInt() val indicatorHeight = iconPlaceable.height + (IndicatorVerticalPadding * 2).roundToPx() val indicatorRipplePlaceable = measurables .fastFirst { it.layoutId == IndicatorRippleLayoutIdTag } .measure( Constraints.fixed( width = totalIndicatorWidth, height = indicatorHeight ) ) val indicatorPlaceable = measurables .fastFirstOrNull { it.layoutId == IndicatorLayoutIdTag } ?.measure( Constraints.fixed( width = animatedIndicatorWidth, height = indicatorHeight ) ) placeIcon(iconPlaceable, indicatorRipplePlaceable, indicatorPlaceable, constraints) } ) } /** * Places the provided [Placeable]s in the center of the provided [constraints]. */ private fun MeasureScope.placeIcon( iconPlaceable: Placeable, indicatorRipplePlaceable: Placeable, indicatorPlaceable: Placeable?, constraints: Constraints ): MeasureResult { val width = constraints.maxWidth val height = constraints.constrainHeight(32.dp.roundToPx()) val iconX = (width - iconPlaceable.width) / 2 val iconY = (height - iconPlaceable.height) / 2 val rippleX = (width - indicatorRipplePlaceable.width) / 2 val rippleY = (height - indicatorRipplePlaceable.height) / 2 return layout(width, height) { indicatorPlaceable?.let { val indicatorX = (width - it.width) / 2 val indicatorY = (height - it.height) / 2 it.placeRelative(indicatorX, indicatorY) } iconPlaceable.placeRelative(iconX, iconY) indicatorRipplePlaceable.placeRelative(rippleX, rippleY) } } private class MappedInteractionSource( underlyingInteractionSource: InteractionSource, private val delta: Offset ) : InteractionSource { private val mappedPresses = mutableMapOf() override val interactions = underlyingInteractionSource.interactions.map { interaction -> when (interaction) { is PressInteraction.Press -> { val mappedPress = mapPress(interaction) mappedPresses[interaction] = mappedPress mappedPress } is PressInteraction.Cancel -> { val mappedPress = mappedPresses.remove(interaction.press) if (mappedPress == null) { interaction } else { PressInteraction.Cancel(mappedPress) } } is PressInteraction.Release -> { val mappedPress = mappedPresses.remove(interaction.press) if (mappedPress == null) { interaction } else { PressInteraction.Release(mappedPress) } } else -> interaction } } private fun mapPress(press: PressInteraction.Press): PressInteraction.Press = PressInteraction.Press(press.pressPosition - delta) } private const val IndicatorRippleLayoutIdTag: String = "indicatorRipple" private const val IndicatorLayoutIdTag: String = "indicator" private const val IconLayoutIdTag: String = "icon" private const val ItemAnimationDurationMillis: Int = 100 private val IndicatorHorizontalPadding: Dp = (64.dp - 24.dp) / 2 private val IndicatorVerticalPadding: Dp = (32.dp - 24.dp) / 2 private val IndicatorVerticalOffset: Dp = 12.dp ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/NestedScrollConnection.kt ================================================ package com.zhangke.framework.composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll fun Modifier.applyNestedScrollConnection( nestedScrollConnection: NestedScrollConnection?, ): Modifier { if (nestedScrollConnection == null) return this return Modifier.nestedScroll(nestedScrollConnection) then this } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/NoDoubleClick.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.clickable import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import kotlin.time.Clock import kotlin.time.ExperimentalTime private var latestClickTime = 0L private var intervalThreshold = 700L @OptIn(ExperimentalTime::class) fun Modifier.noDoubleClick( enabled: Boolean = true, onClickLabel: String? = null, role: Role? = null, onClick: () -> Unit ): Modifier { return clickable( enabled = enabled, onClickLabel = onClickLabel, role = role, onClick = { val currentTime = Clock.System.now().toEpochMilliseconds() if (currentTime - latestClickTime > intervalThreshold) { onClick() } latestClickTime = currentTime }, ) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/NoRippleClick.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @Composable fun Modifier.noRippleClick(enabled: Boolean = true, onClick: () -> Unit): Modifier { return Modifier.clickable( enabled = enabled, interactionSource = remember { MutableInteractionSource() }, onClick = onClick, indication = null, ) then this } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/Offset.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.offset import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalDensity import com.zhangke.framework.utils.pxToDp fun Modifier.offset(offset: Offset): Modifier = composed { val density = LocalDensity.current Modifier.offset( x = offset.x.pxToDp(density), y = offset.y.pxToDp(density), ) then this } val Offset.isZero: Boolean get() = x == 0F && y == 0F ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/PaddingValuesUtils.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.padding import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.platform.LocalLayoutDirection fun Modifier.startPadding(paddings: PaddingValues): Modifier = composed { val dir = LocalLayoutDirection.current this.padding(start = paddings.calculateStartPadding(dir)) } fun Modifier.endPadding(paddings: PaddingValues): Modifier = composed { val dir = LocalLayoutDirection.current this.padding(end = paddings.calculateEndPadding(dir)) } fun Modifier.horizontalPadding(paddings: PaddingValues): Modifier = composed { val dir = LocalLayoutDirection.current this.padding( start = paddings.calculateStartPadding(dir), end = paddings.calculateEndPadding(dir), ) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/Placeholder.kt ================================================ package com.zhangke.framework.composable import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.spring import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import com.eygraber.compose.placeholder.PlaceholderHighlight import com.eygraber.compose.placeholder.material3.fade import com.eygraber.compose.placeholder.material3.placeholder @Composable fun Modifier.freadPlaceholder( visible: Boolean, color: Color = Color.Unspecified, shape: Shape = RectangleShape, // highlight: PlaceholderHighlight? = null, placeholderFadeAnimationSpec: AnimationSpec = spring(), contentFadeAnimationSpec: AnimationSpec = spring(), ): Modifier = then( Modifier.placeholder( visible = visible, color = color, shape = shape, highlight = PlaceholderHighlight.fade(), placeholderFadeAnimationSpec = placeholderFadeAnimationSpec, contentFadeAnimationSpec = contentFadeAnimationSpec, ) ) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/PopupFloatingActionButton.kt ================================================ package com.zhangke.framework.composable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material3.DropdownMenu import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon 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.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.unit.dp @Composable fun PopupFloatingActionButton( modifier: Modifier = Modifier, popupContent: @Composable ColumnScope.() -> Unit, ) { var expanded by remember { mutableStateOf(false) } FloatingActionButton( modifier = modifier, onClick = { if (!expanded) expanded = true }, ) { val rotate by animateFloatAsState(if (expanded) 45f else 0f, label = "PopupFloatingActionButton") Icon( modifier = Modifier.rotate(rotate), imageVector = Icons.Default.Add, contentDescription = "Add", ) } DropdownMenu( modifier = Modifier.size(200.dp), expanded = expanded, onDismissRequest = { expanded = false }) { Box(modifier = Modifier.size(200.dp)) } // if (expanded) { // Popup( // onDismissRequest = { // expanded = false // }, // ) { // Column(modifier = Modifier.size(200.dp)) { // popupContent() // } // } // } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/PopupMenu.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.ColumnScope import androidx.compose.material3.DropdownMenu import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp @Composable fun PopupMenu( expanded: Boolean, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, offset: DpOffset = DpOffset(0.dp, 0.dp), content: @Composable ColumnScope.() -> Unit, ) { DropdownMenu( expanded = expanded, onDismissRequest = onDismissRequest, modifier = modifier, offset = offset, containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.98F), content = content, ) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/PredictiveBackProgressState.kt ================================================ package com.zhangke.framework.composable import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationEventHandler import androidx.navigationevent.compose.rememberNavigationEventState @Stable class PredictiveBackProgressState internal constructor(initial: Float) { var progress by mutableFloatStateOf(initial) // 0f..1f } @Composable fun rememberPredictiveBackProgressState(): PredictiveBackProgressState { val state = rememberSaveable { PredictiveBackProgressState(0f) } val navEventState = rememberNavigationEventState(NavigationEventInfo.None) NavigationEventHandler( state = navEventState, isBackEnabled = true, onBackCancelled = { state.progress = 0f }, onBackCompleted = { state.progress = 0f }, ) return state } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/ScreenUtils.kt ================================================ package com.zhangke.framework.composable import androidx.compose.runtime.Composable import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import com.zhangke.framework.nav.LocalNavBackStack import kotlinx.coroutines.flow.Flow @Composable fun ConsumeOpenScreenFlow( openScreenFlow: Flow, backStack: NavBackStack = LocalNavBackStack.currentOrThrow, ) { ConsumeFlow(openScreenFlow) { val lastItem = backStack.lastOrNull() if (lastItem != it) { backStack.add(it) } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/ScrollTopAppBar.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.background import androidx.compose.foundation.layout.RowScope import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.layout.SubcomposeLayout import com.zhangke.framework.blur.applyBlurEffect import com.zhangke.framework.blur.blurEffectContainerColor import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable fun ScrollTopAppBar( modifier: Modifier, title: @Composable () -> Unit, actions: @Composable RowScope.() -> Unit, scrollBehavior: TopAppBarScrollBehavior, colors: ScrollTopAppBarColors = ScrollTopAppBarColors.default(), navigationIcon: @Composable () -> Unit = {}, ) { val appBarContainerColor by remember(scrollBehavior, colors) { derivedStateOf { val fraction = scrollBehavior.state.overlappedFraction.coerceIn(0F, 1F) lerp(colors.containerColor, colors.scrolledContainerColor, fraction) } } SubcomposeLayout( modifier = modifier.background(appBarContainerColor), ) { constraints -> val topAppBarPlaceable = subcompose("topAppBar") { SingleRowTopAppBar( modifier = modifier.applyBlurEffect(containerColor = appBarContainerColor), navigationIcon = navigationIcon, title = title, actions = actions, colors = TopAppBarColors.default( containerColor = blurEffectContainerColor(containerColor = appBarContainerColor), navigationIconContentColor = colors.contentColor, titleContentColor = colors.contentColor, actionIconContentColor = colors.contentColor, ), ) }.first().measure(constraints) val totalHeight = topAppBarPlaceable.height if (scrollBehavior.state.heightOffsetLimit != -totalHeight.toFloat()) { scrollBehavior.state.heightOffsetLimit = -totalHeight.toFloat() } val heightOffset = scrollBehavior.state.heightOffset val layoutHeight = (totalHeight + heightOffset).roundToInt().coerceAtLeast(0) layout(constraints.maxWidth, layoutHeight) { val topAppBarY = heightOffset.roundToInt() topAppBarPlaceable.placeRelative(0, topAppBarY) } } } data class ScrollTopAppBarColors( val containerColor: Color, val scrolledContainerColor: Color, val contentColor: Color, ) { companion object { @Composable fun default( containerColor: Color = MaterialTheme.colorScheme.surface, scrolledContainerColor: Color = MaterialTheme.colorScheme.surfaceContainer, contentColor: Color = MaterialTheme.colorScheme.onSurface, ): ScrollTopAppBarColors { return ScrollTopAppBarColors( containerColor = containerColor, scrolledContainerColor = scrolledContainerColor, contentColor = contentColor, ) } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/SearchToolbar.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.ColumnScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Clear import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.SearchBar import androidx.compose.material3.Text 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.graphics.vector.rememberVectorPainter @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchToolbar( onBackClick: () -> Unit, placeholderText: String, onQueryChange: (query: String) -> Unit, onSearch: (query: String) -> Unit, content: @Composable ColumnScope.() -> Unit, ) { var query by remember { mutableStateOf("") } SearchBar( query = query, onQueryChange = { query = it onQueryChange(it) }, onSearch = onSearch, leadingIcon = { Toolbar.BackButton(onBackClick = onBackClick) }, trailingIcon = { IconButton(onClick = { query = "" }) { Icon( painter = rememberVectorPainter(image = Icons.Filled.Clear), contentDescription = "clear" ) } }, active = true, placeholder = { Text(text = placeholderText) }, onActiveChange = { if (!it) onBackClick() }, content = content, ) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/Size.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.size import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.geometry.Size import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntSize import com.zhangke.framework.utils.pxToDp fun Modifier.size(size: IntSize): Modifier = composed { val density = LocalDensity.current size(width = size.width.pxToDp(density), height = size.height.pxToDp(density)) } fun Size.aspectRatio(): Float { return width / height } fun IntSize.aspectRatio(): Float { return width.toFloat() / height.toFloat() } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/SlickRoundCornerShape.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.shape.CornerSize import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.toRect import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlin.math.sqrt class SlickRoundCornerShape( private val topStart: CornerSize, private val topEnd: CornerSize, private val bottomEnd: CornerSize, private val bottomStart: CornerSize, ) : Shape { override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density, ): Outline { return createOutline( size = size, topStart = topStart.toPx(size, density), topEnd = topEnd.toPx(size, density), bottomEnd = bottomEnd.toPx(size, density), bottomStart = bottomStart.toPx(size, density), layoutDirection = layoutDirection ) } private fun createOutline( size: Size, topStart: Float, topEnd: Float, bottomEnd: Float, bottomStart: Float, layoutDirection: LayoutDirection, ): Outline { val height = size.height val width = size.width return if (topStart + topEnd + bottomEnd + bottomStart == 0.0f) { Outline.Rectangle(size.toRect()) } else if (topStart == bottomStart && width < (topStart * 2F)) { val radius = topStart val path = Path() if (height > radius * 2) { buildSlickRoundCornerPath(path, size, radius) } else { buildSingleArcPath(path, size, radius) } Outline.Generic(path) } else { Outline.Rounded( RoundRect( rect = size.toRect(), topLeft = CornerRadius(if (layoutDirection == LayoutDirection.Ltr) topStart else topEnd), topRight = CornerRadius(if (layoutDirection == LayoutDirection.Ltr) topEnd else topStart), bottomRight = CornerRadius(if (layoutDirection == LayoutDirection.Ltr) bottomEnd else bottomStart), bottomLeft = CornerRadius(if (layoutDirection == LayoutDirection.Ltr) bottomStart else bottomEnd) ) ) } } private fun buildSlickRoundCornerPath(path: Path, size: Size, radius: Float) { val width = size.width val height = size.height if (width > radius) { path.addRoundRect( RoundRect( rect = size.toRect(), topLeft = CornerRadius(radius), topRight = CornerRadius(0F), bottomRight = CornerRadius(0F), bottomLeft = CornerRadius(radius), ) ) return } val arcHeight = sqrt(radius * radius - (radius - width) * (radius - width)) val yOffset = radius - arcHeight path.arcTo( rect = Rect( left = 0F, top = yOffset, right = width * 2F, bottom = yOffset + arcHeight * 2F, ), startAngleDegrees = 180F, sweepAngleDegrees = 90F, forceMoveTo = true, ) val bottomArcBottom = height - yOffset path.lineTo(x = width, y = bottomArcBottom) path.arcTo( rect = Rect( left = 0F, top = bottomArcBottom - arcHeight * 2, right = width * 2F, bottom = bottomArcBottom, ), startAngleDegrees = 90F, sweepAngleDegrees = 90F, forceMoveTo = true, ) path.lineTo(0F, yOffset + arcHeight) path.close() } private fun buildSingleArcPath(path: Path, size: Size, radius: Float) { path.moveTo(size.width, 0F) path.arcTo( rect = Rect(0F, 0F, radius * 2F, radius * 2F), startAngleDegrees = 90F, sweepAngleDegrees = 180F, forceMoveTo = true, ) path.close() } } fun SlickRoundCornerShape(corner: CornerSize) = SlickRoundCornerShape(corner, corner, corner, corner) fun SlickRoundCornerShape(size: Dp) = SlickRoundCornerShape(CornerSize(size)) fun SlickRoundCornerShape(size: Float) = SlickRoundCornerShape(CornerSize(size)) fun SlickRoundCornerShape(percent: Int) = SlickRoundCornerShape(CornerSize(percent)) fun SlickRoundCornerShape( topStart: Dp = 0.dp, topEnd: Dp = 0.dp, bottomEnd: Dp = 0.dp, bottomStart: Dp = 0.dp ) = SlickRoundCornerShape( topStart = CornerSize(topStart), topEnd = CornerSize(topEnd), bottomEnd = CornerSize(bottomEnd), bottomStart = CornerSize(bottomStart) ) fun SlickRoundCornerShape( topStart: Float = 0.0f, topEnd: Float = 0.0f, bottomEnd: Float = 0.0f, bottomStart: Float = 0.0f ) = SlickRoundCornerShape( topStart = CornerSize(topStart), topEnd = CornerSize(topEnd), bottomEnd = CornerSize(bottomEnd), bottomStart = CornerSize(bottomStart) ) fun SlickRoundCornerShape( /*@IntRange(from = 0, to = 100)*/ topStartPercent: Int = 0, /*@IntRange(from = 0, to = 100)*/ topEndPercent: Int = 0, /*@IntRange(from = 0, to = 100)*/ bottomEndPercent: Int = 0, /*@IntRange(from = 0, to = 100)*/ bottomStartPercent: Int = 0 ) = SlickRoundCornerShape( topStart = CornerSize(topStartPercent), topEnd = CornerSize(topEndPercent), bottomEnd = CornerSize(bottomEndPercent), bottomStart = CornerSize(bottomStartPercent) ) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/Snackbar.kt ================================================ package com.zhangke.framework.composable import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import kotlinx.coroutines.flow.Flow @Composable fun rememberSnackbarHostState(): SnackbarHostState { return remember { SnackbarHostState() } } @Composable fun snackbarHost(hostState: SnackbarHostState): @Composable (() -> Unit) { return { SnackbarHost(hostState = hostState) } } @Composable fun ObserveSnackbar( hostState: SnackbarHostState, messageText: TextString?, actionLabel: String? = null, duration: SnackbarDuration = SnackbarDuration.Short ) { if (!messageText.isNullOrEmpty()) { val message = textString(text = messageText!!) LaunchedEffect(message) { hostState.showSnackbar(message, actionLabel, duration = duration) } } } @Composable fun ConsumeSnackbarFlow( hostState: SnackbarHostState?, messageTextFlow: Flow, actionLabel: String? = null, duration: SnackbarDuration = SnackbarDuration.Short ) { hostState ?: return ConsumeFlow(messageTextFlow) { val message = it.getString().take(180) if (message.isNotEmpty()) { hostState.showSnackbar(message, actionLabel, duration = duration) } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/StateSaver.kt ================================================ package com.zhangke.framework.composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.Saver import androidx.compose.ui.unit.Dp object StateSaver { val MutableNullableDpSaver: Saver, *> = Saver( save = { it.value?.value }, restore = { mutableStateOf(Dp(it)) } ) val MutableDpSaver: Saver, *> = Saver( save = { it.value.value }, restore = { mutableStateOf(Dp(it)) } ) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/StyledIconButton.kt ================================================ package com.zhangke.framework.composable import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector @Composable fun StyledIconButton( modifier: Modifier = Modifier, imageVector: ImageVector, style: IconButtonStyle, onClick: () -> Unit, contentDescription: String? = null, ) { val containerColor: Color val contentColor: Color when (style) { IconButtonStyle.DISABLE -> { // in disable style, btn will use disable-container-color, it's transparent. containerColor = MaterialTheme.colorScheme.primaryContainer contentColor = MaterialTheme.colorScheme.onSurface } IconButtonStyle.ALERT -> { containerColor = MaterialTheme.colorScheme.errorContainer contentColor = MaterialTheme.colorScheme.onErrorContainer } IconButtonStyle.STANDARD -> { containerColor = MaterialTheme.colorScheme.primaryContainer contentColor = MaterialTheme.colorScheme.onPrimaryContainer } IconButtonStyle.ACTIVE -> { containerColor = MaterialTheme.colorScheme.primary contentColor = MaterialTheme.colorScheme.onPrimary } } IconButton( modifier = modifier, onClick = onClick, colors = IconButtonDefaults.iconButtonColors( containerColor = containerColor, contentColor = contentColor, ), ) { Icon( imageVector = imageVector, contentDescription = contentDescription, ) } } enum class IconButtonStyle { /** * 禁用状态 */ DISABLE, /** * 警告状态,略微负面。 */ ALERT, /** * 普通状态,中性。 */ STANDARD, /** * 活跃状态,正面,希望被点击。 */ ACTIVE, } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/StyledTextButton.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @Composable fun StyledTextButton( modifier: Modifier, text: String, style: TextButtonStyle, onClick: () -> Unit, contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding, ) { val containerColor: Color val textColor: Color when (style) { TextButtonStyle.DISABLE -> { // in disable style, btn will use disable-container-color, it's transparent. containerColor = MaterialTheme.colorScheme.primaryContainer textColor = MaterialTheme.colorScheme.onSurface } TextButtonStyle.ALERT -> { containerColor = MaterialTheme.colorScheme.errorContainer textColor = MaterialTheme.colorScheme.onErrorContainer } TextButtonStyle.STANDARD -> { containerColor = MaterialTheme.colorScheme.primaryContainer textColor = MaterialTheme.colorScheme.onPrimaryContainer } TextButtonStyle.ACTIVE -> { containerColor = MaterialTheme.colorScheme.primary textColor = MaterialTheme.colorScheme.onPrimary } } TextButton( modifier = modifier, colors = ButtonDefaults.textButtonColors( containerColor = containerColor, ), enabled = style != TextButtonStyle.DISABLE, onClick = onClick, contentPadding = contentPadding, ) { Text( text = text, color = textColor, ) } } enum class TextButtonStyle { /** * 禁用状态 */ DISABLE, /** * 警告状态,略微负面。 */ ALERT, /** * 普通状态,中性。 */ STANDARD, /** * 活跃状态,正面,希望被点击。 */ ACTIVE, } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/SystemUi.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.navigationBars import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import com.zhangke.framework.utils.pxToDp @Composable fun getNavigationBarHeight(insets: WindowInsets = WindowInsets.navigationBars): Dp { val density = LocalDensity.current return insets.getTop(density).pxToDp(density) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/TabIndicator.kt ================================================ package com.zhangke.framework.composable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material3.TabPosition import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.unit.Dp fun Modifier.ownTabIndicatorOffset( currentTabPosition: TabPosition, currentTabWidth: Dp = currentTabPosition.width ): Modifier = composed( inspectorInfo = debugInspectorInfo { name = "tabIndicatorOffset" value = currentTabPosition } ) { val indicatorOffset by animateDpAsState( targetValue = currentTabPosition.left, animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), label = "" ) fillMaxWidth() .wrapContentSize(Alignment.BottomStart) .offset(x = indicatorOffset + ((currentTabPosition.width - currentTabWidth) / 2)) .width(currentTabWidth) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/TabsTopAppBar.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.background import androidx.compose.foundation.layout.RowScope import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.unit.dp import com.zhangke.framework.blur.applyBlurEffect import com.zhangke.framework.blur.blurEffectContainerColor import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable fun TabsTopAppBar( modifier: Modifier, title: @Composable () -> Unit, actions: @Composable RowScope.() -> Unit, selectedTabIndex: Int, scrollBehavior: TopAppBarScrollBehavior, tabCount: Int, tabContent: @Composable (index: Int) -> Unit, onTabClick: (index: Int) -> Unit, colors: TabsTopAppBarColors = TabsTopAppBarColors.default(), navigationIcon: @Composable () -> Unit = {}, ) { val appBarContainerColor by remember(scrollBehavior, colors) { derivedStateOf { val fraction = scrollBehavior.state.overlappedFraction.coerceIn(0F, 1F) lerp(colors.containerColor, colors.scrolledContainerColor, fraction) } } SubcomposeLayout( modifier = modifier.background(appBarContainerColor), ) { constraints -> val tabRowPlaceable = subcompose("tabRow") { FreadTabRow( selectedTabIndex = selectedTabIndex, containerColor = appBarContainerColor, tabCount = tabCount, tabContent = tabContent, onTabClick = onTabClick, ) }.first().measure(constraints) val topAppBarPlaceable = subcompose("topAppBar") { SingleRowTopAppBar( modifier = modifier.applyBlurEffect(containerColor = appBarContainerColor), navigationIcon = navigationIcon, title = title, actions = actions, height = 48.dp, colors = TopAppBarColors.default( containerColor = blurEffectContainerColor(containerColor = appBarContainerColor), navigationIconContentColor = colors.contentColor, titleContentColor = colors.contentColor, actionIconContentColor = colors.contentColor, ), ) }.first().measure(constraints) val totalHeight = topAppBarPlaceable.height + tabRowPlaceable.height if (scrollBehavior.state.heightOffsetLimit != -totalHeight.toFloat()) { scrollBehavior.state.heightOffsetLimit = -totalHeight.toFloat() } val heightOffset = scrollBehavior.state.heightOffset val tabOffset = heightOffset.coerceIn(-tabRowPlaceable.height.toFloat(), 0f) val topOffset = heightOffset - tabOffset val layoutHeight = (totalHeight + heightOffset).roundToInt().coerceAtLeast(0) layout(constraints.maxWidth, layoutHeight) { val topAppBarY = topOffset.roundToInt() val tabRowY = (topAppBarPlaceable.height + tabOffset + topOffset).roundToInt() tabRowPlaceable.placeRelative(0, tabRowY) topAppBarPlaceable.placeRelative(0, topAppBarY) } } } data class TabsTopAppBarColors( val containerColor: Color, val scrolledContainerColor: Color, val contentColor: Color, ) { companion object { @Composable fun default( containerColor: Color = MaterialTheme.colorScheme.surface, scrolledContainerColor: Color = MaterialTheme.colorScheme.surfaceContainer, contentColor: Color = MaterialTheme.colorScheme.onSurface, ): TabsTopAppBarColors { return TabsTopAppBarColors( containerColor = containerColor, scrolledContainerColor = scrolledContainerColor, contentColor = contentColor, ) } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/TextString.kt ================================================ package com.zhangke.framework.composable import androidx.compose.runtime.Composable import com.zhangke.fread.localization.LocalizedString import kotlinx.coroutines.flow.MutableSharedFlow import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource sealed class TextString { class ResourceText(val resId: Int, val formatArgs: Array) : TextString() class ComposeResourceText(val res: StringResource, val formatArgs: Array) : TextString() class StringText(val string: String) : TextString() { override fun toString(): String { return string } } } fun textOf(stringResId: Int, vararg formatArgs: Any): TextString { return TextString.ResourceText(stringResId, arrayOf(*formatArgs)) } fun textOf(string: String): TextString { return TextString.StringText(string) } fun textOf(stringResource: StringResource, vararg formatArgs: Any): TextString { return TextString.ComposeResourceText(stringResource, arrayOf(*formatArgs)) } @Composable fun textString(text: TextString): String { return when (text) { is TextString.StringText -> text.string is TextString.ComposeResourceText -> stringResource(text.res, *text.formatArgs) is TextString.ResourceText -> stringResource(text.resId, *text.formatArgs) } } @Composable expect fun stringResource(resId: Int, vararg formatArgs: Any): String @Composable fun TextString?.isNullOrEmpty(): Boolean { return this == null || textString(text = this).isEmpty() } fun Throwable.toTextStringOrNull(): TextString? { val errorMessage = this.message if (errorMessage.isNullOrEmpty()) return null return textOf(errorMessage) } suspend fun MutableSharedFlow.emitTextMessageFromThrowable(t: Throwable) { val message = t.toTextStringOrNull() ?: textOf(getString(LocalizedString.unknownError)) this.emit(message) } expect suspend fun TextString.getString(): String ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/TextWithIcon.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text 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.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit @Composable fun TextWithIcon( modifier: Modifier = Modifier, text: String, color: Color = Color.Unspecified, fontSize: TextUnit = TextUnit.Unspecified, fontStyle: FontStyle? = null, fontWeight: FontWeight? = null, fontFamily: FontFamily? = null, letterSpacing: TextUnit = TextUnit.Unspecified, textDecoration: TextDecoration? = null, textAlign: TextAlign? = null, lineHeight: TextUnit = TextUnit.Unspecified, overflow: TextOverflow = TextOverflow.Clip, softWrap: Boolean = true, maxLines: Int = Int.MAX_VALUE, startIcon: (@Composable () -> Unit)? = null, endIcon: (@Composable () -> Unit)? = null, onTextLayout: (TextLayoutResult) -> Unit = {}, style: TextStyle = LocalTextStyle.current ) { Row( modifier = modifier, horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { startIcon?.invoke() Text( text = text, color = color, fontSize = fontSize, fontWeight = fontWeight, fontFamily = fontFamily, fontStyle = fontStyle, letterSpacing = letterSpacing, textDecoration = textDecoration, textAlign = textAlign, lineHeight = lineHeight, overflow = overflow, softWrap = softWrap, maxLines = maxLines, onTextLayout = onTextLayout, style = style, ) endIcon?.invoke() } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/Toolbar.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.RowScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Download import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp object ToolbarTokens { val ContainerHeight = 64.0.dp val LeadingIconSize = 24.0.dp val TrailingIconSize = 24.0.dp val TitleLargeSize = 22.sp val TopAppBarHorizontalPadding = 4.dp val TitleLargeLineHeight = 28.0.sp val TitleLargeTracking = 0.0.sp val DefaultLineHeightStyle = LineHeightStyle( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None, ) val DefaultTextStyle = TextStyle.Default.copy( lineHeightStyle = DefaultLineHeightStyle, ) val titleTextStyle = DefaultTextStyle.copy( fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Normal, fontSize = TitleLargeSize, lineHeight = TitleLargeLineHeight, letterSpacing = TitleLargeTracking, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun Toolbar( title: String, onBackClick: (() -> Unit)? = null, actions: @Composable RowScope.() -> Unit = {} ) { val navigationIcon: (@Composable (() -> Unit)) = if (onBackClick != null) { @Composable { Toolbar.BackButton(onBackClick = onBackClick) } } else { {} } TopAppBar( navigationIcon = navigationIcon, actions = actions, title = { Text(text = title) }, ) } object Toolbar { @Composable fun BackButton( onBackClick: () -> Unit, modifier: Modifier = Modifier, tint: Color = LocalContentColor.current, ) { IconButton( modifier = modifier, onClick = onBackClick ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, "back", tint = tint, ) } } @Composable fun DownloadButton( modifier: Modifier = Modifier, tint: Color = LocalContentColor.current, onClick: () -> Unit, ) { SimpleIconButton( modifier = modifier, onClick = onClick, tint = tint, imageVector = Icons.Default.Download, contentDescription = "Download", ) } @Composable fun DeleteButton( onDeleteClick: () -> Unit, modifier: Modifier = Modifier, tint: Color = LocalContentColor.current, ) { IconButton( modifier = modifier, onClick = onDeleteClick ) { Icon( imageVector = Icons.Default.Delete, "delete", tint = tint, ) } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/TopAppBar.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.LocalContentColor import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Stable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.layoutId import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlin.math.max @Composable fun SingleRowTopAppBar( title: @Composable () -> Unit, modifier: Modifier = Modifier, navigationIcon: @Composable () -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, colors: TopAppBarColors = TopAppBarColors.default(), height: Dp = TopAppBarDefaults.TopAppBarExpandedHeight, ) { val actionsRow: @Composable () -> Unit = { Row( horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, content = actions, ) } Box( modifier = modifier.drawBehind { val color = colors.containerColor if (color != Color.Unspecified) { drawRect(color = color) } }, ) { SingleRowTopAppBarLayout( modifier = Modifier.windowInsetsPadding(windowInsets).clipToBounds(), navigationIconContentColor = colors.navigationIconContentColor, titleContentColor = colors.titleContentColor, actionIconContentColor = colors.actionIconContentColor, title = title, titleTextStyle = ToolbarTokens.titleTextStyle, navigationIcon = navigationIcon, actions = actionsRow, height = height, ) } } @Stable class TopAppBarColors( val containerColor: Color, val navigationIconContentColor: Color, val titleContentColor: Color, val actionIconContentColor: Color, ) { companion object { @Composable fun default( containerColor: Color = TopAppBarDefaults.topAppBarColors().containerColor, navigationIconContentColor: Color = TopAppBarDefaults.topAppBarColors().navigationIconContentColor, titleContentColor: Color = TopAppBarDefaults.topAppBarColors().titleContentColor, actionIconContentColor: Color = TopAppBarDefaults.topAppBarColors().actionIconContentColor, ): TopAppBarColors { return TopAppBarColors( containerColor = containerColor, navigationIconContentColor = navigationIconContentColor, titleContentColor = titleContentColor, actionIconContentColor = actionIconContentColor, ) } } } @Composable private fun SingleRowTopAppBarLayout( modifier: Modifier, navigationIconContentColor: Color, titleContentColor: Color, actionIconContentColor: Color, title: @Composable () -> Unit, titleTextStyle: TextStyle, navigationIcon: @Composable () -> Unit, actions: @Composable () -> Unit, height: Dp, ) { Layout( content = { Box(Modifier.layoutId("navigationIcon").padding(start = TopAppBarHorizontalPadding)) { CompositionLocalProvider( LocalContentColor provides navigationIconContentColor, content = navigationIcon, ) } Box( modifier = Modifier.layoutId("title").padding(horizontal = TopAppBarHorizontalPadding) ) { CompositionLocalProvider(LocalContentColor provides titleContentColor) { ProvideTextStyle(titleTextStyle, content = title) } } Box(Modifier.layoutId("actionIcons").padding(end = TopAppBarHorizontalPadding)) { CompositionLocalProvider( LocalContentColor provides actionIconContentColor, content = actions, ) } }, modifier = modifier, measurePolicy = { measurables, constraints -> val navigationIconPlaceable = measurables.first { it.layoutId == "navigationIcon" } .measure(constraints.copy(minWidth = 0)) val actionIconsPlaceable = measurables.first { it.layoutId == "actionIcons" } .measure(constraints.copy(minWidth = 0)) val maxTitleWidth = if (constraints.maxWidth == Constraints.Infinity) { constraints.maxWidth } else { (constraints.maxWidth - navigationIconPlaceable.width - actionIconsPlaceable.width) .coerceAtLeast(0) } val titlePlaceable = measurables.first { it.layoutId == "title" } .measure(constraints.copy(minWidth = 0, maxWidth = maxTitleWidth)) val maxLayoutHeight = max( height.roundToPx(), max( titlePlaceable.height, max(navigationIconPlaceable.height, actionIconsPlaceable.height), ), ) layout(constraints.maxWidth, maxLayoutHeight) { navigationIconPlaceable.placeRelative( x = 0, y = (maxLayoutHeight - navigationIconPlaceable.height) / 2, ) val start = max(TopAppBarTitleInset.roundToPx(), navigationIconPlaceable.width) val end = actionIconsPlaceable.width var titleX = Alignment.Start.align( size = titlePlaceable.width, space = constraints.maxWidth, layoutDirection = LayoutDirection.Ltr, ) if (titleX < start) { titleX += (start - titleX) } else if (titleX + titlePlaceable.width > constraints.maxWidth - end) { titleX += ((constraints.maxWidth - end) - (titleX + titlePlaceable.width)) } val titleY = (maxLayoutHeight - titlePlaceable.height) / 2 titlePlaceable.placeRelative(titleX, titleY) actionIconsPlaceable.placeRelative( x = constraints.maxWidth - actionIconsPlaceable.width, y = (maxLayoutHeight - actionIconsPlaceable.height) / 2, ) } }, ) } private val TopAppBarHorizontalPadding = 4.dp private val TopAppBarTitleInset = 16.dp - TopAppBarHorizontalPadding ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/TopBarWithTabLayout.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.layoutId import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import com.zhangke.framework.ktx.second import com.zhangke.framework.utils.pxToDp @OptIn(ExperimentalMaterial3Api::class) @Composable fun TopBarWithTabLayout( topBarContent: @Composable BoxScope.() -> Unit, tabContent: @Composable BoxScope.() -> Unit, modifier: Modifier = Modifier, scrollableContent: @Composable () -> Unit, ) { val density = LocalDensity.current var topBarHeightInPx: Int by rememberSaveable { mutableIntStateOf(0) } var tabHeightInPx: Int by rememberSaveable { mutableIntStateOf(0) } val nestedScrollConnection = rememberCollapsableTopBarScrollConnection( minPx = 0F, maxPx = (topBarHeightInPx + tabHeightInPx).toFloat(), ) val process by rememberUpdatedState(newValue = nestedScrollConnection.progress) Box( modifier = modifier .fillMaxSize() .nestedScroll(nestedScrollConnection) ) { Layout( modifier = Modifier, content = { Box( modifier = Modifier.fillMaxSize().layoutId("scrollable"), ) { val topPaddingInPx = topBarHeightInPx + tabHeightInPx val newPaddings = updateTopPadding((topPaddingInPx.pxToDp(density))) CompositionLocalProvider( LocalContentPadding provides newPaddings ) { scrollableContent() } } Box( modifier = Modifier .layoutId("topBar") .fillMaxWidth() .onSizeChanged { topBarHeightInPx = it.height }, ) { topBarContent() } Box( modifier = Modifier.layoutId("tab").fillMaxWidth() .onSizeChanged { tabHeightInPx = it.height }, ) { tabContent() } }, measurePolicy = { measurables, constraints -> val scrollableContentPlaceable = measurables.first { it.layoutId == "scrollable" }.measure(constraints) val topBarPlaceable = measurables.first { it.layoutId == "topBar" }.measure(constraints) val tabBarPlaceable = measurables.first { it.layoutId == "tab" }.measure(constraints) layout(constraints.maxWidth, constraints.maxHeight) { scrollableContentPlaceable.placeRelative(0, 0) val totalHeaderHeight = topBarHeightInPx + tabHeightInPx val processedOffset = totalHeaderHeight * process val tabBarYOffset = topBarHeightInPx - processedOffset.coerceAtLeast(0F) tabBarPlaceable.placeRelative(0, tabBarYOffset.toInt()) val topBarYOffset = -((processedOffset - tabHeightInPx).coerceAtLeast(0F)) topBarPlaceable.placeRelative(0, topBarYOffset.toInt()) } }, ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun TopBarWithTabLayoutClassical( topBarContent: @Composable BoxScope.() -> Unit, tabContent: @Composable BoxScope.() -> Unit, modifier: Modifier = Modifier, windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(), scrollableContent: @Composable BoxScope.() -> Unit, ) { val density = LocalDensity.current var topBarHeightInPx: Int by rememberSaveable { mutableIntStateOf(0) } var tabHeightInPx: Int by rememberSaveable { mutableIntStateOf(0) } val nestedScrollConnection = rememberCollapsableTopBarScrollConnection( minPx = 0F, maxPx = (topBarHeightInPx + tabHeightInPx).toFloat(), ) val process by rememberUpdatedState(newValue = nestedScrollConnection.progress) Box( modifier = modifier .fillMaxSize() .nestedScroll(nestedScrollConnection) ) { Layout( modifier = Modifier, content = { Box( modifier = Modifier .fillMaxWidth() .onSizeChanged { topBarHeightInPx = it.height }, ) { topBarContent() } Box( modifier = Modifier.fillMaxWidth() .onSizeChanged { tabHeightInPx = it.height }, ) { tabContent() } Box( modifier = Modifier.fillMaxSize(), ) { scrollableContent() } }, measurePolicy = { measurables, constraints -> val topBarPlaceable = measurables.first().measure(constraints) val tabBarPlaceable = measurables.second().measure(constraints) val scrollableContentPlaceable = measurables[2].measure(constraints) layout(constraints.maxWidth, constraints.maxHeight) { val totalHeaderHeight = topBarHeightInPx + tabHeightInPx val processedOffset = totalHeaderHeight * process val tabBarYOffset = topBarHeightInPx - processedOffset.coerceAtLeast(0F) tabBarPlaceable.placeRelative(0, tabBarYOffset.toInt()) val topBarYOffset = -((processedOffset - tabHeightInPx).coerceAtLeast(0F)) topBarPlaceable.placeRelative(0, topBarYOffset.toInt()) scrollableContentPlaceable.placeRelative( 0, totalHeaderHeight - processedOffset.toInt() ) } }, ) // val statusBarHeight = windowInsets.getTop(density).pxToDp(density) // if (statusBarHeight > 0.dp) { // Box( // modifier = Modifier // .fillMaxWidth() // .height(statusBarHeight) // .background(colors.containerColor) // ) // } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/TwoTextsInRow.kt ================================================ package com.zhangke.framework.composable import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.FirstBaseline import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp @Composable fun TwoTextsInRow( firstText: @Composable () -> Unit, secondText: @Composable () -> Unit, spacing: Dp, modifier: Modifier = Modifier ) { val density = LocalDensity.current val spacingPx = with(density) { spacing.roundToPx().toFloat() } Box(modifier = modifier) { Layout( content = { firstText() secondText() }, measurePolicy = { measurables, constraints -> val firstTextMeasurable = measurables[0] val firstTextPlaceable = firstTextMeasurable.measure( constraints.copy(maxWidth = constraints.maxWidth - spacingPx.toInt()) ) val secondTextMeasurable = measurables[1] val secondTextPlaceable = secondTextMeasurable.measure( constraints.copy(maxWidth = constraints.maxWidth - spacingPx.toInt() - firstTextPlaceable.width) ) val firstBaseLine = firstTextPlaceable[FirstBaseline] val secondBaseLine = secondTextPlaceable[FirstBaseline] val secondTextHeight = secondTextPlaceable.height layout( width = constraints.maxWidth, height = maxOf(firstTextPlaceable.height, secondTextHeight) ) { firstTextPlaceable.place( x = 0, y = 0, ) secondTextPlaceable.place( x = firstTextPlaceable.width + spacingPx.toInt(), y = firstBaseLine - secondBaseLine, ) } } ) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/VelocityExt.kt ================================================ package com.zhangke.framework.composable import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.Velocity fun Velocity.toOffset() = Offset(x = x, y = y) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/VerticalIndentLayout.kt ================================================ package com.zhangke.framework.composable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout import androidx.compose.ui.unit.Dp @Composable fun VerticalIndentLayout( modifier: Modifier, indentHeight: Dp, headerContent: @Composable () -> Unit, indentContent: @Composable () -> Unit, ) { Layout( modifier = modifier, measurePolicy = { measurables, constraints -> val headerPlaceable = measurables.first().measure(constraints) val indentPlaceable = measurables.last().measure(constraints) val containerHeight = headerPlaceable.height + indentPlaceable.height - indentHeight.roundToPx() layout(constraints.maxWidth, containerHeight) { headerPlaceable.placeRelative(0, 0) indentPlaceable.placeRelative( x = 0, y = headerPlaceable.height - indentHeight.roundToPx() ) } }, content = { headerContent() indentContent() } ) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/collapsable/CollapsableTopBarLayout.kt ================================================ package com.zhangke.framework.composable.collapsable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp @Composable fun CollapsableTopBarLayout( modifier: Modifier = Modifier, minTopBarHeight: Dp, contentCanScrollBackward: State, topBar: @Composable (collapsableProgress: Float) -> Unit, scrollableContent: @Composable () -> Unit, ) { val density = LocalDensity.current val minTopBarHeightPx = with(density) { minTopBarHeight.toPx() } var maxTopBarHeightPx: Float? by remember { mutableStateOf(null) } var progress: Float by remember { mutableFloatStateOf(0F) } val connection = rememberCollapsableTopBarLayoutConnection( contentCanScrollBackward = contentCanScrollBackward, maxPx = maxTopBarHeightPx ?: 0F, minPx = minTopBarHeightPx, ) progress = connection.progress Column(modifier = modifier.nestedScroll(connection)) { Box( modifier = Modifier .scrollable(rememberScrollState(), Orientation.Vertical) .onGloballyPositioned { if (maxTopBarHeightPx == null || maxTopBarHeightPx == 0F) { maxTopBarHeightPx = it.size.height.toFloat() } } ) { topBar(progress) } Box( modifier = Modifier.scrollable(rememberScrollState(), Orientation.Vertical) ) { scrollableContent() } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/collapsable/CollapsableTopBarLayoutConnection.kt ================================================ package com.zhangke.framework.composable.collapsable import androidx.compose.animation.core.animate import androidx.compose.animation.core.calculateTargetValue import androidx.compose.animation.core.exponentialDecay import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.unit.Velocity @Composable fun rememberCollapsableTopBarLayoutConnection( contentCanScrollBackward: State, maxPx: Float, minPx: Float, ): ICollapsableTopBarLayoutConnection { return if (maxPx <= 0F) { remember { StaticTopBarLayoutConnection() } } else { rememberSaveable(maxPx, minPx, saver = CollapsableTopBarLayoutConnection.Saver) { CollapsableTopBarLayoutConnection(maxPx, minPx) }.also { it.contentCanScrollBackward = contentCanScrollBackward } } } interface ICollapsableTopBarLayoutConnection : NestedScrollConnection { val progress: Float } class StaticTopBarLayoutConnection : NestedScrollConnection, ICollapsableTopBarLayoutConnection { override val progress: Float = 0F } class CollapsableTopBarLayoutConnection( private val maxPx: Float, private val minPx: Float, ) : NestedScrollConnection, ICollapsableTopBarLayoutConnection { var contentCanScrollBackward: State? = null private var topBarHeight: Float = maxPx set(value) { field = value progress = 1 - (topBarHeight - minPx) / (maxPx - minPx) } override var progress: Float by mutableFloatStateOf(0F) private set override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { if (available.y == 0f) return Velocity.Zero val startHeight = topBarHeight val targetHeight = exponentialDecay().calculateTargetValue( initialValue = startHeight, initialVelocity = available.y, ).coerceAtLeast(minPx).coerceAtMost(maxPx) if (topBarHeight == targetHeight) return available animate( initialValue = startHeight, targetValue = targetHeight ) { value, _ -> topBarHeight = value } val consumedY = targetHeight - startHeight return if (available.y > 0 && consumedY > 0 || available.y < 0 && consumedY < 0) { available } else { Velocity.Zero } } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset { return handleScroll(available) } override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { return handleScroll(available) } private fun handleScroll(available: Offset): Offset { val height = topBarHeight if (height == minPx) { if (available.y > 0F) { return if (contentCanScrollBackward?.value == true) { Offset.Zero } else { topBarHeight += available.y Offset(0F, available.y) } } } if (height + available.y > maxPx) { topBarHeight = maxPx return Offset(0f, maxPx - height) } if (height + available.y < minPx) { topBarHeight = minPx return Offset(0f, minPx - height) } topBarHeight += available.y return Offset(0f, available.y) } companion object { val Saver: Saver = listSaver( save = { listOf(it.minPx, it.maxPx, it.topBarHeight) }, restore = { CollapsableTopBarLayoutConnection( minPx = it[0], maxPx = it[1], ).apply { topBarHeight = it[2] } } ) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/collapsable/ScrollUpTopBarLayout.kt ================================================ package com.zhangke.framework.composable.collapsable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalDensity @Composable fun ScrollUpTopBarLayout( modifier: Modifier = Modifier, topBarContent: @Composable BoxScope.(progress: Float) -> Unit, headerContent: @Composable BoxScope.(progress: Float) -> Unit, contentCanScrollBackward: State, /** * true: headerContent will immersive behind the top bar. * false: headerContent will below the top bar. */ immersiveToTopBar: Boolean = true, /** * PaddingValues.top is the current visible height occupied by top bar + header area. */ scrollableContent: @Composable BoxScope.(PaddingValues, Float) -> Unit, ) { var topBarHeightPx: Int by rememberSaveable { mutableIntStateOf(0) } var headerContentHeightPx: Int by rememberSaveable { mutableIntStateOf(0) } val density = LocalDensity.current val nestedScrollConnection = if (immersiveToTopBar) { rememberCollapsableTopBarLayoutConnection( contentCanScrollBackward = contentCanScrollBackward, maxPx = headerContentHeightPx.toFloat(), minPx = topBarHeightPx.toFloat(), ) } else { rememberCollapsableTopBarLayoutConnection( contentCanScrollBackward = contentCanScrollBackward, maxPx = headerContentHeightPx.toFloat(), minPx = 0F, ) } val progress by rememberUpdatedState(newValue = nestedScrollConnection.progress) val topPaddingPx = calculateScrollableTopPadding( topBarHeightPx = topBarHeightPx, headerContentHeightPx = headerContentHeightPx, progress = progress, immersiveToTopBar = immersiveToTopBar, ) val scrollableContentPadding = with(density) { PaddingValues(top = topPaddingPx.toDp()) } Box( modifier = modifier .fillMaxSize() .nestedScroll(nestedScrollConnection), ) { Layout( modifier = Modifier, content = { Box( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), ) { topBarContent(progress) } Box( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), ) { headerContent(progress) } Box( modifier = Modifier .fillMaxSize(), ) { scrollableContent(scrollableContentPadding, progress) } }, measurePolicy = { measurables, constraints -> val topBarPlaceable = measurables.first().measure(constraints) val headerContentPlaceable = measurables[1].measure( constraints.copy(maxHeight = constraints.maxHeight * 6) ) val scrollableContentPlaceable = measurables[2].measure(constraints) if (topBarHeightPx != topBarPlaceable.measuredHeight) { topBarHeightPx = topBarPlaceable.measuredHeight } if (headerContentHeightPx != headerContentPlaceable.measuredHeight) { headerContentHeightPx = headerContentPlaceable.measuredHeight } layout(constraints.maxWidth, constraints.maxHeight) { val totalScrollOffset = if (immersiveToTopBar) { headerContentHeightPx - topBarHeightPx } else { headerContentHeightPx } val progressOffset = totalScrollOffset * progress val headerYOffset = if (immersiveToTopBar) { -((progressOffset).coerceAtLeast(0F)) } else { topBarHeightPx - ((progressOffset).coerceAtLeast(0F)) } scrollableContentPlaceable.placeRelative(0, 0) headerContentPlaceable.placeRelative(0, headerYOffset.toInt()) topBarPlaceable.placeRelative(0, 0) } }, ) } } private fun calculateScrollableTopPadding( topBarHeightPx: Int, headerContentHeightPx: Int, progress: Float, immersiveToTopBar: Boolean, ): Float { val headerYOffset = if (immersiveToTopBar) { val totalScrollOffset = (headerContentHeightPx - topBarHeightPx).coerceAtLeast(0) -(totalScrollOffset * progress) } else { topBarHeightPx - (headerContentHeightPx * progress) } return (headerContentHeightPx + headerYOffset).coerceAtLeast(0F) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/icons/Toufu.kt ================================================ package com.zhangke.framework.composable.icons import androidx.compose.material.icons.Icons import androidx.compose.material.icons.materialIcon import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathNode import androidx.compose.ui.graphics.vector.addPathNodes private var _tofu: ImageVector? = null val Icons.Filled.Tofu: ImageVector get() { if (_tofu != null) { return _tofu!! } _tofu = materialIcon(name = "Filled.Tofu") { addPath( buildPathData( width = 24.0f, height = 24.0f, radius = 4.0f, ), fill = SolidColor(Color.Gray.copy(alpha = 0.6F)) ) } return _tofu!! } private fun buildPathData(width: Float, height: Float, radius: Float): List { return addPathNodes( "M${radius},0 " + "H${width - radius} " + "A${radius},${radius} 0 0,1 $width,$radius " + "V${height - radius} " + "A${radius},${radius} 0 0,1 ${width - radius},$height " + "H$radius " + "A${radius},${radius} 0 0,1 0,${height - radius} " + "V$radius " + "A${radius},${radius} 0 0,1 $radius,0 " + "Z" ) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/image/viewer/ImageViewer.kt ================================================ package com.zhangke.framework.composable.image.viewer import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.addPointerInputChange import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.toSize import com.zhangke.framework.composable.BackHandler import com.zhangke.framework.utils.asSize import com.zhangke.framework.utils.pxToDp import kotlinx.coroutines.launch private val infinityConstraints = Constraints() @OptIn(ExperimentalComposeUiApi::class) @Composable fun ImageViewer( state: ImageViewerState, modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() var latestSize: Size? by remember { mutableStateOf(null) } BackHandler(true) { coroutineScope.launch { state.startDismiss() } } BoxWithConstraints(modifier = modifier) { if (constraints.hasBoundedWidth && constraints.hasBoundedHeight) { state.updateLayoutSize(constraints.asSize()) } Layout( modifier = Modifier .onGloballyPositioned { position -> val currentSize = position.size.toSize() if (currentSize != latestSize) { state.updateLayoutSize(currentSize) latestSize = currentSize } } .pointerInput(state) { detectTapGestures( onDoubleTap = { if (state.exceed) { coroutineScope.launch { state.animateToStandard() } } else { coroutineScope.launch { state.animateToBig(it) } } }, onTap = { coroutineScope.launch { state.startDismiss() } }, ) } .draggableInfinity( exceed = state.exceed, isBigVerticalImage = state.isBigVerticalImage, onDrag = { offset -> state.drag(offset) }, onDragStopped = { velocity -> coroutineScope.launch { state.dragStop(velocity) } }, ) .pointerInput(state) { detectZoom { centroid, zoom -> state.zoom(centroid, zoom) } }, content = { Box( modifier = Modifier .offset( x = state.currentOffsetXPixel.pxToDp(density), y = state.currentOffsetYPixel.pxToDp(density) ) .width(state.currentWidthPixel.pxToDp(density)) .height(state.currentHeightPixel.pxToDp(density)) ) { content() } } ) { measurables, constraints -> if (measurables.size != 1) { throw IllegalStateException("InfiniteBox is only allowed to have one children!") } val placeable = measurables.first().measure(infinityConstraints) layout(constraints.maxWidth, constraints.maxHeight) { placeable.placeRelative(0, 0) } } } } private fun Modifier.draggableInfinity( exceed: Boolean, isBigVerticalImage: Boolean, onDrag: (dragAmount: Offset) -> Unit, onDragStopped: (velocity: Velocity) -> Unit, ): Modifier { val velocityTracker = VelocityTracker() return Modifier.pointerInput(exceed || isBigVerticalImage) { if (exceed) { detectDragGestures( onDrag = { change, dragAmount -> velocityTracker.addPointerInputChange(change) onDrag(dragAmount) }, onDragEnd = { val velocity = velocityTracker.calculateVelocity() onDragStopped(velocity) }, onDragCancel = { val velocity = velocityTracker.calculateVelocity() onDragStopped(velocity) }, ) } else { detectVerticalDragGestures( onVerticalDrag = { change, dragAmount -> velocityTracker.addPointerInputChange(change) onDrag(Offset(x = 0F, y = dragAmount)) }, onDragEnd = { val velocity = velocityTracker.calculateVelocity() onDragStopped(velocity) }, onDragCancel = { val velocity = velocityTracker.calculateVelocity() onDragStopped(velocity) }, ) } } then this } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/image/viewer/ImageViewerState.kt ================================================ package com.zhangke.framework.composable.image.viewer import androidx.compose.animation.core.AnimationScope import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.AnimationVector2D import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.animateDecay import androidx.compose.animation.core.animateTo import androidx.compose.animation.core.exponentialDecay import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.isSpecified import androidx.compose.ui.geometry.isUnspecified import androidx.compose.ui.unit.Velocity import com.zhangke.framework.composable.Bounds import com.zhangke.framework.composable.aspectRatio import com.zhangke.framework.composable.toOffset import com.zhangke.framework.utils.equalsExactly @Composable fun rememberImageViewerState( aspectRatio: Float, initialSize: Size = Size.Unspecified, minimumScale: Float = 1f, maximumScale: Float = 3f, onDismissRequest: () -> Unit, ): ImageViewerState { val dismissRequest by rememberUpdatedState(newValue = onDismissRequest) return rememberSaveable( saver = ImageViewerState.Saver, ) { ImageViewerState( aspectRatio = aspectRatio, initialSize = initialSize, minimumScale = minimumScale, maximumScale = maximumScale, ) }.apply { this.onDismissRequest = dismissRequest } } @Stable class ImageViewerState( private val aspectRatio: Float, private val initialSize: Size, private val minimumScale: Float = 1f, private val maximumScale: Float = 3f, ) { var onDismissRequest: (() -> Unit)? = null private var _currentWidthPixel = mutableFloatStateOf(0F) private var _currentHeightPixel = mutableFloatStateOf(0F) private var _currentOffsetXPixel = mutableFloatStateOf(0F) private var _currentOffsetYPixel = mutableFloatStateOf(0F) val currentWidthPixel: Float by _currentWidthPixel val currentHeightPixel: Float by _currentHeightPixel val currentOffsetXPixel: Float by _currentOffsetXPixel val currentOffsetYPixel: Float by _currentOffsetYPixel private var layoutSize: Size = Size.Zero private val standardWidth: Float get() = layoutSize.width private val standardHeight: Float get() = standardWidth / aspectRatio val exceed: Boolean get() = !_currentWidthPixel.floatValue.equalsExactly(layoutSize.width) internal val isBigVerticalImage: Boolean get() { if (layoutSize == Size.Zero) return false return aspectRatio <= layoutSize.aspectRatio() } private var flingAnimation: AnimationScope? = null private var scaleAnimation: AnimationScope? = null private var resumeOffsetYAnimation: AnimationScope? = null private val draggableBounds: Bounds get() { return calculateDragBounds( imageWidth = _currentWidthPixel.floatValue, imageHeight = _currentHeightPixel.floatValue, ) } init { if (initialSize != Size.Unspecified) { _currentWidthPixel.floatValue = initialSize.width _currentHeightPixel.floatValue = initialSize.height } } fun updateLayoutSize(size: Size) { if (size == layoutSize) return layoutSize = size onLayoutSizeChanged() } private fun onLayoutSizeChanged() { val offsetY = if (isBigVerticalImage) { 0F } else { layoutSize.height / 2F - standardHeight / 2F } if (initialSize.isUnspecified) { _currentWidthPixel.floatValue = standardWidth _currentHeightPixel.floatValue = standardHeight _currentOffsetXPixel.floatValue = 0F _currentOffsetYPixel.floatValue = offsetY } else { _currentWidthPixel.floatValue = standardWidth _currentHeightPixel.floatValue = standardHeight _currentOffsetXPixel.floatValue = 0F _currentOffsetYPixel.floatValue = offsetY } } suspend fun animateToStandard() { val layoutSize = layoutSize if (layoutSize == Size.Zero) return val targetWidth = standardWidth val targetHeight = standardHeight animateToTarget( targetWidth = targetWidth, targetHeight = targetHeight, targetOffsetX = 0F, targetOffsetY = layoutSize.height / 2F - targetHeight / 2F, ) } suspend fun animateToBig(point: Offset) { val layoutSize = layoutSize if (layoutSize == Size.Zero) return val targetWidth = standardWidth * maximumScale val targetHeight = targetWidth / aspectRatio var targetOffsetX = currentOffsetXPixel * maximumScale var targetOffsetY = layoutSize.height / 2F - targetHeight / 2F if (point.isSpecified && point.isValid()) { // tap point must be in the image bounds if (point.y < currentOffsetYPixel || point.y > (currentOffsetYPixel + currentHeightPixel)) return val xRatio = point.x / currentWidthPixel val yRatio = (point.y - currentOffsetYPixel) / currentHeightPixel targetOffsetX = -(targetWidth * xRatio - point.x) targetOffsetY = point.y - targetHeight * yRatio } val dragBounds = calculateDragBounds( imageWidth = targetWidth, imageHeight = targetHeight, ) animateToTarget( targetWidth = targetWidth, targetHeight = targetHeight, targetOffsetX = dragBounds.coerceInX(targetOffsetX), targetOffsetY = dragBounds.coerceInY(targetOffsetY), ) } fun drag(dragAmount: Offset) { cancelAnimation() if (exceed || isBigVerticalImage) { dragForVisit(dragAmount) } else { dragForExit(dragAmount) } } private fun dragForVisit(dragAmount: Offset) { val currentOffset = Offset(_currentOffsetXPixel.floatValue, _currentOffsetYPixel.floatValue) val newOffset = currentOffset + dragAmount val fixedOffset = draggableBounds.coerceIn(newOffset) _currentOffsetXPixel.floatValue = fixedOffset.x _currentOffsetYPixel.floatValue = fixedOffset.y } private fun dragForExit(dragAmount: Offset) { val dragAmountY = dragAmount.y if (dragAmountY <= 0F) { if (_currentOffsetYPixel.floatValue > 0F) { _currentOffsetYPixel.floatValue += dragAmountY } } else { _currentOffsetYPixel.floatValue += dragAmountY } } suspend fun dragStop(initialVelocity: Velocity) { cancelAnimation() if (!exceed && !isBigVerticalImage) { dragStopForExit() return } val initialValue = Offset(_currentOffsetXPixel.floatValue, _currentOffsetYPixel.floatValue) AnimationState( typeConverter = Offset.VectorConverter, initialValue = initialValue, initialVelocity = initialVelocity.toOffset(), ).animateDecay(exponentialDecay()) { flingAnimation = this if (draggableBounds.outsideAbsolute(value) || velocity.getDistance() <= 300 ) { flingAnimation = null cancelAnimation() return@animateDecay } val progressOffset = draggableBounds.coerceIn(value) _currentOffsetXPixel.floatValue = progressOffset.x _currentOffsetYPixel.floatValue = progressOffset.y } } private suspend fun dragStopForExit() { cancelAnimation() val standardOffsetY = layoutSize.height / 2F - _currentHeightPixel.floatValue / 2F val totalAmount = _currentOffsetYPixel.floatValue - standardOffsetY val exitOffsetYThresholds = standardHeight * 0.3F if (totalAmount > exitOffsetYThresholds) { startDismiss() } else { val anim = AnimationState(initialValue = _currentOffsetYPixel.floatValue) anim.animateTo( targetValue = standardOffsetY, animationSpec = tween(durationMillis = ImageViewerDefault.ANIMATION_DURATION), ) { resumeOffsetYAnimation = this _currentOffsetYPixel.floatValue = value } } } internal suspend fun startDismiss() { if ((initialSize.isUnspecified || initialSize.isEmpty())) { onDismissRequest?.invoke() return } val targetWidth = if (initialSize.isEmpty()) _currentWidthPixel.floatValue else initialSize.width val targetHeight = if (initialSize.isEmpty()) _currentHeightPixel.floatValue else initialSize.height val targetOffsetX = _currentOffsetXPixel.floatValue val targetOffsetY = _currentOffsetYPixel.floatValue animateToTarget( targetWidth = targetWidth, targetHeight = targetHeight, targetOffsetX = targetOffsetX, targetOffsetY = targetOffsetY, ) onDismissRequest?.invoke() } private suspend fun animateToTarget( targetWidth: Float, targetHeight: Float, targetOffsetX: Float, targetOffsetY: Float, ) { cancelAnimation() val startWidth = currentWidthPixel val startHeight = currentHeightPixel val startOffsetX = currentOffsetXPixel val startOffsetY = currentOffsetYPixel if (startWidth != targetWidth || startHeight != targetHeight || startOffsetX != targetOffsetX || startOffsetY != targetOffsetY ) { val widthDiff = targetWidth - startWidth val heightDiff = targetHeight - startHeight val offsetXDiff = targetOffsetX - startOffsetX val offsetYDiff = targetOffsetY - startOffsetY val anim = AnimationState(initialValue = 0f) anim.animateTo( targetValue = 1f, animationSpec = tween(durationMillis = ImageViewerDefault.ANIMATION_DURATION), ) { scaleAnimation = this val progress = value if (widthDiff != 0F) { _currentWidthPixel.floatValue = startWidth + widthDiff * progress } if (heightDiff != 0F) { _currentHeightPixel.floatValue = startHeight + heightDiff * progress } if (offsetXDiff != 0F) { _currentOffsetXPixel.floatValue = startOffsetX + offsetXDiff * progress } if (offsetYDiff != 0F) { _currentOffsetYPixel.floatValue = startOffsetY + offsetYDiff * progress } } } } private fun cancelAnimation() { scaleAnimation?.takeIf { it.isRunning }?.cancelAnimation() scaleAnimation = null flingAnimation?.takeIf { it.isRunning }?.cancelAnimation() flingAnimation = null resumeOffsetYAnimation?.takeIf { it.isRunning }?.cancelAnimation() resumeOffsetYAnimation = null } internal fun zoom(centroid: Offset, zoom: Float) { val newWidth = (currentWidthPixel * zoom).coerceInWidth() val newHeight = (currentHeightPixel * zoom).coerceInHeight() val xRatio = (centroid.x - currentOffsetXPixel) / currentWidthPixel val yRatio = (centroid.y - currentOffsetYPixel) / currentHeightPixel _currentWidthPixel.floatValue = newWidth _currentHeightPixel.floatValue = newHeight val xOffset = -(newWidth * xRatio - centroid.x) val yOffset = -(newHeight * yRatio - centroid.y) val bounds = calculateDragBounds(newWidth, newHeight) _currentOffsetYPixel.floatValue = bounds.coerceInY(yOffset) if (newWidth == standardWidth) { _currentOffsetXPixel.floatValue = 0F } else { _currentOffsetXPixel.floatValue = bounds.coerceInX(xOffset) } } private fun calculateDragBounds(imageWidth: Float, imageHeight: Float): Bounds { val left: Float val right: Float if (imageWidth > layoutSize.width) { left = -(imageWidth - layoutSize.width) right = 0F } else { left = (layoutSize.width - imageWidth) / 2F right = left } val top: Float val bottom: Float if (imageHeight > layoutSize.height) { top = -(imageHeight - layoutSize.height) bottom = 0F } else { top = (layoutSize.height - imageHeight) / 2F bottom = top } return Bounds( left = left, top = top, right = right, bottom = bottom, ) } private fun Float.coerceInWidth(): Float { val maxWidth = standardWidth * maximumScale return coerceAtLeast(standardWidth).coerceAtMost(maxWidth) } private fun Float.coerceInHeight(): Float { val maxHeight = standardHeight * maximumScale return coerceAtLeast(standardHeight).coerceAtMost(maxHeight) } internal companion object { private const val MAGIC_NUMBER = -21039142F private val Size.magicWidth: Float get() = if (isUnspecified) MAGIC_NUMBER else width private val Size.magicHeight: Float get() = if (isUnspecified) MAGIC_NUMBER else height private fun magicSize(width: Float, height: Float): Size { if (width == MAGIC_NUMBER || height == MAGIC_NUMBER) return Size.Unspecified return Size(width = width, height = height) } val Saver: Saver = listSaver( save = { listOf( it.aspectRatio, it.initialSize.magicWidth, it.initialSize.magicHeight, it.minimumScale, it.maximumScale, ) }, restore = { ImageViewerState( aspectRatio = it[0] as Float, initialSize = magicSize(width = it[1] as Float, height = it[2] as Float), minimumScale = it[3] as Float, maximumScale = it[4] as Float, ) } ) } } object ImageViewerDefault { const val ANIMATION_DURATION = 200 } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/image/viewer/TransformGestureDetector.kt ================================================ package com.zhangke.framework.composable.image.viewer import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.calculateCentroid import androidx.compose.foundation.gestures.calculateCentroidSize import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.positionChanged import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastForEach import kotlin.math.abs suspend fun PointerInputScope.detectZoom( onGesture: (centroid: Offset, zoom: Float) -> Unit ) { awaitEachGesture { var zoom = 1f var pastTouchSlop = false val touchSlop = viewConfiguration.touchSlop awaitFirstDown(requireUnconsumed = false) do { val event = awaitPointerEvent() val canceled = event.changes.fastAny { it.isConsumed } if (!canceled) { val zoomChange = event.calculateZoom() if (!pastTouchSlop) { zoom *= zoomChange val centroidSize = event.calculateCentroidSize(useCurrent = false) val zoomMotion = abs(1 - zoom) * centroidSize if (zoomMotion > touchSlop) { pastTouchSlop = true } } if (pastTouchSlop) { val centroid = event.calculateCentroid(useCurrent = false) if (zoomChange != 1f) { onGesture(centroid, zoomChange) } event.changes.fastForEach { if (it.positionChanged()) { it.consume() } } } } } while (!canceled && event.changes.fastAny { it.pressed }) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/infinite/InfiniteBox.kt ================================================ package com.zhangke.framework.composable.infinite import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.addPointerInputChange import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.toSize import com.zhangke.framework.composable.Bounds import com.zhangke.framework.ktx.isSingle import kotlinx.coroutines.launch import kotlin.math.roundToInt private val infinityConstraints = Constraints() @Composable fun InfiniteBox( modifier: Modifier = Modifier, state: InfinityBoxState = rememberInfinityBoxState(), content: @Composable () -> Unit, ) { val coroutineScope = rememberCoroutineScope() var layoutSize by remember { mutableStateOf(Size.Zero) } Layout( modifier = modifier .onSizeChanged { layoutSize = it.toSize() state.layoutSize = it.toSize() } .draggableInfinity( enabled = state.exceed, onDrag = { dragAmount -> state.drag(dragAmount) }, onDragStopped = { initialVelocity -> coroutineScope.launch { state.fling(initialVelocity) } } ), content = content, ) { measurables, constraints -> if (measurables.isSingle().not()) { throw IllegalStateException("InfiniteBox is only allowed to have one children!") } val placeable = measurables.first().measure(infinityConstraints) state.exceed = placeable.width > constraints.maxWidth || placeable.height > constraints.maxHeight state.draggableBounds = Bounds( left = (-(placeable.width - layoutSize.width)).coerceAtMost(0F), top = (-(placeable.height - layoutSize.height)).coerceAtMost(0F), right = 0F, bottom = 0F, ) layout(constraints.maxWidth, constraints.maxHeight) { placeable.placeRelative( x = state.currentOffset.x.roundToInt(), y = state.currentOffset.y.roundToInt(), ) } } } private fun Modifier.draggableInfinity( enabled: Boolean, onDrag: (dragAmount: Offset) -> Unit, onDragStopped: (velocity: Velocity) -> Unit, ): Modifier { val velocityTracker = VelocityTracker() return pointerInput(enabled) { if (enabled) { detectDragGestures( onDrag = { change, dragAmount -> velocityTracker.addPointerInputChange(change) onDrag(dragAmount) }, onDragEnd = { val velocity = velocityTracker.calculateVelocity() onDragStopped(velocity) }, onDragCancel = { val velocity = velocityTracker.calculateVelocity() onDragStopped(velocity) }, ) } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/infinite/InfinityBoxState.kt ================================================ package com.zhangke.framework.composable.infinite import androidx.compose.animation.core.AnimationScope import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.AnimationVector2D import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.animateDecay import androidx.compose.animation.core.exponentialDecay import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.unit.Velocity import com.zhangke.framework.composable.Bounds import com.zhangke.framework.composable.toOffset @Composable fun rememberInfinityBoxState(): InfinityBoxState { return rememberSaveable(saver = InfinityBoxState.Saver) { InfinityBoxState() } } @Stable class InfinityBoxState { var exceed: Boolean by mutableStateOf(false) private val _currentOffset = mutableStateOf(Offset.Zero) val currentOffset by _currentOffset internal var layoutSize = Size.Zero internal var draggableBounds = Bounds.EMPTY set(value) { cancelAnimation() field = value _currentOffset.value = Offset.Zero } private var flingAnimation: AnimationScope? = null fun moveToCenter() { cancelAnimation() if (!exceed) return _currentOffset.value = Offset( x = draggableBounds.left / 2F, y = draggableBounds.top / 2F, ) } fun drag(dragAmount: Offset) { cancelAnimation() if (!exceed) return val newOffset = currentOffset + dragAmount _currentOffset.value = draggableBounds.coerceIn(newOffset) } suspend fun fling(initialVelocity: Velocity) { if (!exceed) return val initialValue = currentOffset AnimationState( typeConverter = Offset.VectorConverter, initialValue = initialValue, initialVelocity = initialVelocity.toOffset(), ).animateDecay(exponentialDecay()) { flingAnimation = this if (draggableBounds.outsideAbsolute(value) || velocity.getDistance() <= 300 ) { flingAnimation = null cancelAnimation() return@animateDecay } _currentOffset.value = draggableBounds.coerceIn(value) } } private fun cancelAnimation() { val animation = flingAnimation ?: return flingAnimation = null if (!animation.isRunning) return animation.cancelAnimation() } companion object { val Saver: Saver = Saver( save = { emptyArray() }, restore = { InfinityBoxState() }, ) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/inline/InlineVideoLazyColumn.kt ================================================ package com.zhangke.framework.composable.inline import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.sensitive.SensitiveLazyColumn import com.zhangke.framework.composable.sensitive.SensitiveLazyColumnState import com.zhangke.framework.composable.sensitive.transform @Composable fun InlineVideoLazyColumn( modifier: Modifier = Modifier, state: LazyListState = rememberLazyListState(), contentPadding: PaddingValues = PaddingValues(0.dp), reverseLayout: Boolean = false, verticalArrangement: Arrangement.Vertical = if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, horizontalAlignment: Alignment.Horizontal = Alignment.Start, flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, indexMapping: ((Int) -> Int) = { it }, content: LazyListScope.() -> Unit, ) { val sensitiveState = remember(state) { mutableStateOf( SensitiveLazyColumnState( firstVisibleIndex = 0, firstVisiblePercent = 0F, lastVisibleIndex = 0, lastVisiblePercent = 0F, isScrollInProgress = false, ) ) } val localSensitiveState by sensitiveState val playableIndexRecorder = remember(state) { PlayableIndexRecorder() } LaunchedEffect(localSensitiveState) { playableIndexRecorder.updateLayoutState(localSensitiveState) } CompositionLocalProvider( LocalPlayableIndexRecorder provides playableIndexRecorder ) { SensitiveLazyColumn( modifier = modifier, state = state, onSensitiveLayoutChanged = { sensitiveState.value = it.transform(indexMapping) }, contentPadding = contentPadding, reverseLayout = reverseLayout, verticalArrangement = verticalArrangement, horizontalAlignment = horizontalAlignment, flingBehavior = flingBehavior, userScrollEnabled = userScrollEnabled, content = content, ) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/inline/PlayableIndexRecorderLocal.kt ================================================ package com.zhangke.framework.composable.inline import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.staticCompositionLocalOf import com.zhangke.framework.composable.sensitive.SensitiveLazyColumnState import com.zhangke.framework.ktx.isSingle import com.zhangke.framework.ktx.second import kotlin.math.max val LocalPlayableIndexRecorder: ProvidableCompositionLocal = staticCompositionLocalOf { null } class PlayableIndexRecorder { companion object { private const val PLAYABLE_PERCENT_THRESHOLD = 0.3F private const val UNSPECIFIED_INDEX = -1 } private val _recorder = mutableSetOf() private val _currentActiveIndex = mutableIntStateOf(-1) val currentActiveIndex: Int by _currentActiveIndex fun updateLayoutState(state: SensitiveLazyColumnState) { val threshold = PLAYABLE_PERCENT_THRESHOLD val currentActiveInlineIndex = _currentActiveIndex.intValue if (currentActiveInlineIndex >= 0) { val currentActivePlayablePercent = state.getVisiblePercentOfIndex(currentActiveInlineIndex) if (currentActivePlayablePercent >= threshold) return } _currentActiveIndex.intValue = getCenterIndex(state) ?: UNSPECIFIED_INDEX } fun changeActiveIndex(index: Int) { _currentActiveIndex.intValue = index } private fun getCenterIndex(state: SensitiveLazyColumnState): Int? { val indexList = getIntervalIndexList(state.firstVisibleIndex, state.lastVisibleIndex) if (indexList.isEmpty()) return null if (indexList.isSingle()) { return state.getValidateIndexOrNull(indexList.first(), PLAYABLE_PERCENT_THRESHOLD) } if (indexList.size == 2) { val maxIndex = max(indexList.first(), indexList.second()) return state.getValidateIndexOrNull(maxIndex, PLAYABLE_PERCENT_THRESHOLD) } val centerIndex = indexList[indexList.size / 2] return state.getValidateIndexOrNull(centerIndex, PLAYABLE_PERCENT_THRESHOLD) } private fun SensitiveLazyColumnState.getValidateIndexOrNull( index: Int, playablePercentThreshold: Float, ): Int? { val percent = getVisiblePercentOfIndex(index) if (percent >= playablePercentThreshold) return index return null } private fun getIntervalIndexList(startIndex: Int, endIndex: Int): List { val intervalList = mutableListOf() _recorder.sorted().forEach { index -> if (index > endIndex) return@forEach if (index in startIndex..endIndex) { intervalList += index } } return intervalList } fun recordePlayableIndex(index: Int) { _recorder += index } fun removePlayableIndex(index: Int) { _recorder -= index } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/pick/PickVisualMediaLauncherContainer.kt ================================================ package com.zhangke.framework.composable.pick import androidx.compose.runtime.Composable import com.zhangke.framework.utils.PlatformUri @Composable expect fun PickVisualMediaLauncherContainer( onResult: (List) -> Unit, maxItems: Int = 1, content: @Composable PickVisualMediaLauncherContainerScope.() -> Unit, ) expect class PickVisualMediaLauncherContainerScope { fun launchImage() fun launchMedia() fun launchVideo() fun launchImageFile() fun launchVideoFile() } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/sensitive/SensitiveLazyColumn.kt ================================================ package com.zhangke.framework.composable.sensitive import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import kotlin.math.max @Composable fun SensitiveLazyColumn( modifier: Modifier = Modifier, state: LazyListState = rememberLazyListState(), onSensitiveLayoutChanged: (SensitiveLazyColumnState) -> Unit, contentPadding: PaddingValues = PaddingValues(0.dp), reverseLayout: Boolean = false, verticalArrangement: Arrangement.Vertical = if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, horizontalAlignment: Alignment.Horizontal = Alignment.Start, flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, content: LazyListScope.() -> Unit, ) { val layoutInfo by remember { derivedStateOf { state.layoutInfo } } val visibleItemsInfo = layoutInfo.visibleItemsInfo if (visibleItemsInfo.isNotEmpty()) { val firstItemLayoutInfo = visibleItemsInfo.first() val lastItemLayoutInfo = visibleItemsInfo.last() val firstVisiblePercent = state.visibilityPercent(firstItemLayoutInfo) val lastVisiblePercent = state.visibilityPercent(lastItemLayoutInfo) val isScrollInProgress = state.isScrollInProgress LaunchedEffect( visibleItemsInfo, isScrollInProgress, firstVisiblePercent, lastVisiblePercent, isScrollInProgress, ) { onSensitiveLayoutChanged( SensitiveLazyColumnState( firstVisibleIndex = firstItemLayoutInfo.index, firstVisiblePercent = firstVisiblePercent, lastVisibleIndex = lastItemLayoutInfo.index, lastVisiblePercent = lastVisiblePercent, isScrollInProgress = isScrollInProgress, ) ) } } LazyColumn( modifier = modifier, state = state, contentPadding = contentPadding, reverseLayout = reverseLayout, verticalArrangement = verticalArrangement, horizontalAlignment = horizontalAlignment, flingBehavior = flingBehavior, userScrollEnabled = userScrollEnabled, content = content, ) } fun LazyListState.visibilityPercent(info: LazyListItemInfo): Float { val cutTop = max(0, layoutInfo.viewportStartOffset - info.offset) val cutBottom = max(0, info.offset + info.size - layoutInfo.viewportEndOffset) return max(0f, 100f - (cutTop + cutBottom) * 100f / info.size) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/sensitive/SensitiveLazyColumnState.kt ================================================ package com.zhangke.framework.composable.sensitive data class SensitiveLazyColumnState( val firstVisibleIndex: Int, val firstVisiblePercent: Float, val lastVisibleIndex: Int, val lastVisiblePercent: Float, val isScrollInProgress: Boolean, ) { fun getVisiblePercentOfIndex(index: Int): Float { if (index == firstVisibleIndex) return firstVisiblePercent if (index == lastVisibleIndex) return lastVisiblePercent if (index in firstVisibleIndex..lastVisibleIndex) return 1F return 0F } } fun SensitiveLazyColumnState.transform(indexMapping: (Int) -> Int): SensitiveLazyColumnState{ return this.copy( firstVisibleIndex = indexMapping(firstVisibleIndex), lastVisibleIndex = indexMapping(lastVisibleIndex), ) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/topout/TopOutTopBarLayout.kt ================================================ package com.zhangke.framework.composable.topout import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.StateSaver import com.zhangke.framework.utils.dpToPx import com.zhangke.framework.utils.pxToDp @Composable fun TopOutTopBarLayout( modifier: Modifier = Modifier, topBar: @Composable () -> Unit, content: @Composable () -> Unit, ) { val density = LocalDensity.current var topBarHeightState by rememberSaveable(saver = StateSaver.MutableDpSaver) { mutableStateOf(0.dp) } var topMarginState by rememberSaveable(saver = StateSaver.MutableDpSaver) { mutableStateOf(0.dp) } val finalModifier = if (topBarHeightState == 0.dp) { Modifier.then(modifier) } else { val connection = rememberTopOutTopBarLayoutConnection( topBarHeight = topBarHeightState.dpToPx(density), ) topMarginState = connection.topMargin.pxToDp(density) Modifier .then(modifier) .nestedScroll(connection) } Box(modifier = finalModifier) { Box( modifier = Modifier .scrollable(rememberScrollState(), Orientation.Vertical) .padding(top = topMarginState), ) { content() } Box( modifier = Modifier .onGloballyPositioned { val heightDp = it.size.height.pxToDp(density) if (heightDp > 0.dp && heightDp != topBarHeightState) { topBarHeightState = heightDp } } .graphicsLayer(translationY = (-(topBarHeightState - topMarginState)).dpToPx(density)), ) { topBar() } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/topout/TopOutTopBarLayoutConnection.kt ================================================ package com.zhangke.framework.composable.topout import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource @Composable fun rememberTopOutTopBarLayoutConnection( topBarHeight: Float, ): TopOutTopBarLayoutConnection { return rememberSaveable(topBarHeight, saver = TopOutTopBarLayoutConnection.Saver) { TopOutTopBarLayoutConnection(topBarHeight) } } class TopOutTopBarLayoutConnection( private val topBarHeight: Float, initialTopMargin: Float = topBarHeight, ) : NestedScrollConnection { companion object { val Saver: Saver = Saver( save = { arrayOf(it.topBarHeight, it.topMargin) }, restore = { TopOutTopBarLayoutConnection(it.first(), it[1]) }, ) } var topMargin by mutableStateOf(initialTopMargin) // content to screen margin private var _topMargin: Float = initialTopMargin private set(value) { field = value topMargin = value } override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val margin = _topMargin if (margin + available.y > topBarHeight) { _topMargin = topBarHeight return Offset(0f, topBarHeight - margin) } if (margin + available.y < 0F) { _topMargin = 0F return Offset(0f, -margin) } _topMargin += available.y return Offset(0f, available.y) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/composable/video/VideoPlayer.kt ================================================ package com.zhangke.framework.composable.video import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import io.github.kdroidfilter.composemediaplayer.InitialPlayerState import io.github.kdroidfilter.composemediaplayer.VideoPlayerSurface import io.github.kdroidfilter.composemediaplayer.rememberVideoPlayerState import io.github.kdroidfilter.composemediaplayer.VideoPlayerError as KdVideoPlayerError import io.github.kdroidfilter.composemediaplayer.VideoPlayerState as KdVideoPlayerState @Stable class VideoPlayerController internal constructor( val state: KdVideoPlayerState, initialContentScale: ContentScale, ) { private var rememberedUnmuteVolume by mutableFloatStateOf(1f) var contentScale by mutableStateOf(initialContentScale) private set val isPlaying: Boolean get() = state.isPlaying val isBuffering: Boolean get() = state.isLoading val isMuted: Boolean get() = state.volume <= 0f val currentTimeInSeconds: Float get() = state.currentTime.toFloat() val totalDurationInSeconds: Float get() = resolveDurationSeconds() val hasPlaybackEnded: Boolean get() { val duration = totalDurationInSeconds if (duration <= 0f) return false if (state.loop || state.isPlaying || state.isLoading) return false return currentTimeInSeconds >= (duration - 0.2f) } val lastError: KdVideoPlayerError? get() = state.error val positionText: String get() = state.positionText val durationText: String get() = state.durationText val progress: Float get() = state.sliderPos fun load( mediaUrl: String, autoPlay: Boolean = true, ) { val initialState = if (autoPlay) { InitialPlayerState.PLAY } else { InitialPlayerState.PAUSE } state.openUri(mediaUrl, initialState) } fun play() { if (hasPlaybackEnded) { state.seekTo(0F) } state.play() } fun pause() { state.pause() } fun stop() { state.stop() } fun togglePlayPause() { if (state.isPlaying) { state.pause() } else { state.play() } } fun mute() { val currentVolume = state.volume if (currentVolume > 0f) { rememberedUnmuteVolume = currentVolume } state.volume = 0f } fun unmute() { state.volume = rememberedUnmuteVolume.coerceIn(0.01f, 1f) } fun toggleMute() { if (isMuted) { unmute() } else { mute() } } fun setMuted(muted: Boolean) { if (muted) { mute() } else { unmute() } } fun setLooping(looping: Boolean) { state.loop = looping } fun setPlaybackSpeed(speed: Float) { state.playbackSpeed = speed.coerceIn(0.5f, 2f) } fun setVolume(level: Float) { val safeLevel = level.coerceIn(0f, 1f) if (safeLevel > 0f) { rememberedUnmuteVolume = safeLevel } state.volume = safeLevel } fun seekToProgress(progress: Float) { val target = progress.coerceIn(0f, 1000f) state.sliderPos = target state.userDragging = false state.seekTo(target) } fun seekTo(seconds: Float?) { if (seconds == null) return seekToSeconds(seconds) } fun seekToSeconds(seconds: Float) { val duration = totalDurationInSeconds if (duration <= 0f) return seekToProgress(seconds / duration * 1000f) } fun updateContentScale(contentScale: ContentScale) { this.contentScale = contentScale } fun toggleFullScreen() { state.toggleFullscreen() } fun clearError() { state.clearError() } private fun resolveDurationSeconds(): Float { val metadataDurationSeconds = state.metadata.duration ?.takeIf { it > 0L } ?.toFloat() ?.div(1000f) if (metadataDurationSeconds != null) return metadataDurationSeconds return parseDurationText(state.durationText) } } @Composable fun rememberVideoPlayerController( mediaUrl: String, autoPlay: Boolean = true, initialMuted: Boolean = false, initialPlaybackSpeed: Float = 1f, initialContentScale: ContentScale = ContentScale.Crop, isLooping: Boolean = true, startTimeInSeconds: Float? = null, ): VideoPlayerController { val playerState = rememberVideoPlayerState() val controller = remember(playerState) { VideoPlayerController( state = playerState, initialContentScale = initialContentScale, ) } LaunchedEffect(mediaUrl, autoPlay) { controller.load( mediaUrl = mediaUrl, autoPlay = autoPlay, ) } LaunchedEffect(isLooping) { controller.setLooping(isLooping) } LaunchedEffect(initialPlaybackSpeed) { controller.setPlaybackSpeed(initialPlaybackSpeed) } LaunchedEffect(initialContentScale) { controller.updateContentScale(initialContentScale) } LaunchedEffect(initialMuted) { controller.setMuted(initialMuted) } LaunchedEffect(startTimeInSeconds) { startTimeInSeconds?.let(controller::seekToSeconds) } return controller } @Composable fun VideoPlayer( mediaUrl: String, modifier: Modifier = Modifier, autoPlay: Boolean = true, initialMuted: Boolean = false, initialPlaybackSpeed: Float = 1f, initialContentScale: ContentScale = ContentScale.Crop, isLooping: Boolean = true, startTimeInSeconds: Float? = null, overlay: @Composable () -> Unit = {}, ) { val controller = rememberVideoPlayerController( mediaUrl = mediaUrl, autoPlay = autoPlay, initialMuted = initialMuted, initialPlaybackSpeed = initialPlaybackSpeed, initialContentScale = initialContentScale, isLooping = isLooping, startTimeInSeconds = startTimeInSeconds, ) VideoPlayer( controller = controller, modifier = modifier, overlay = overlay, ) } @Composable fun VideoPlayer( controller: VideoPlayerController, modifier: Modifier = Modifier, overlay: @Composable () -> Unit = {}, ) { VideoPlayerSurface( playerState = controller.state, modifier = modifier, contentScale = controller.contentScale, overlay = overlay, ) } private fun parseDurationText(durationText: String): Float { if (durationText.isBlank()) return 0f val parts = durationText.split(":") return when (parts.size) { 2 -> { val minutes = parts[0].toFloatOrNull() ?: return 0f val seconds = parts[1].toFloatOrNull() ?: return 0f minutes * 60f + seconds } 3 -> { val hours = parts[0].toFloatOrNull() ?: return 0f val minutes = parts[1].toFloatOrNull() ?: return 0f val seconds = parts[2].toFloatOrNull() ?: return 0f hours * 3600f + minutes * 60f + seconds } else -> 0f } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/controller/CommonLoadableController.kt ================================================ package com.zhangke.framework.controller import com.zhangke.framework.composable.TextString import com.zhangke.framework.utils.LoadState import kotlinx.coroutines.CoroutineScope data class CommonLoadableUiState( override val dataList: List, override val initializing: Boolean, override val refreshing: Boolean, override val loadMoreState: LoadState, override val errorMessage: TextString?, ) : LoadableUiState> { override fun copyObject( dataList: List, initializing: Boolean, refreshing: Boolean, loadMoreState: LoadState, errorMessage: TextString? ): CommonLoadableUiState { return copy( dataList = dataList, initializing = initializing, refreshing = refreshing, loadMoreState = loadMoreState, errorMessage = errorMessage, ) } } class CommonLoadableController( coroutineScope: CoroutineScope, onPostSnackMessage: (TextString) -> Unit, ) : LoadableController>( coroutineScope = coroutineScope, initialUiState = CommonLoadableUiState( dataList = emptyList(), initializing = false, refreshing = false, loadMoreState = LoadState.Idle, errorMessage = null, ), onPostSnackMessage = onPostSnackMessage, ) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/controller/LoadableController.kt ================================================ package com.zhangke.framework.controller import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.textOf import com.zhangke.framework.composable.toTextStringOrNull import com.zhangke.framework.utils.LoadState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** * type DATA is the type of the data to be loaded, * type IMPL is the type of the implementation of the LoadableUiState. */ interface LoadableUiState> { val dataList: List val initializing: Boolean val refreshing: Boolean val loadMoreState: LoadState val errorMessage: TextString? fun copyObject( dataList: List = this.dataList, initializing: Boolean = this.initializing, refreshing: Boolean = this.refreshing, loadMoreState: LoadState = this.loadMoreState, errorMessage: TextString? = this.errorMessage, ): IMPL } /** * type DATA is the type of the data to be loaded, * type IMPL is the type of the implementation of the LoadableUiState. */ open class LoadableController>( private val coroutineScope: CoroutineScope, initialUiState: IMPL, private val onPostSnackMessage: (TextString) -> Unit, ) { val mutableUiState: MutableStateFlow = MutableStateFlow(initialUiState) val uiState = mutableUiState.asStateFlow() private var initJob: Job? = null private var refreshJob: Job? = null private var loadMoreJob: Job? = null /** * 一般来说初始化的时候调用一次,之后只需要调用 onRefresh 和 onLoadMore 即可。 * 如果提供了 getDataFromLocal 参数,那么会先从本地获取数据,然后再调用 getDataFromServer。 */ fun initData( getDataFromServer: suspend () -> Result>, getDataFromLocal: (suspend () -> List)? = null, ) { mutableUiState.update { it.copyObject(dataList = emptyList()) } initJob?.cancel() initJob = coroutineScope.launch { mutableUiState.update { it.copyObject(initializing = true) } if (getDataFromLocal != null) { val localData = getDataFromLocal() if (localData.isNotEmpty()) { mutableUiState.update { it.copyObject( dataList = localData, initializing = false, ) } } } getDataFromServer().handleAsRefresh() } } fun onRefresh( hideRefreshing: Boolean = false, getDataFromServer: suspend () -> Result>, ) { if (mutableUiState.value.refreshing) return mutableUiState.update { it.copyObject(refreshing = !hideRefreshing, errorMessage = null) } loadMoreJob?.cancel() refreshJob?.cancel() refreshJob = coroutineScope.launch { getDataFromServer().handleAsRefresh() } } private fun Result>.handleAsRefresh() { this.onSuccess { list -> mutableUiState.update { it.copyObject( dataList = list, refreshing = false, initializing = false, ) } }.onFailure { e -> val errorMessage = e.message?.let { textOf(it) } if (uiState.value.dataList.isEmpty()) { mutableUiState.update { it.copyObject( errorMessage = errorMessage, refreshing = false, initializing = false, ) } } else { errorMessage?.let(onPostSnackMessage) mutableUiState.update { it.copyObject( refreshing = false, initializing = false, ) } } } } fun onLoadMore( loadMoreFromServer: suspend () -> Result>, ) { if (mutableUiState.value.refreshing) return if (mutableUiState.value.loadMoreState == LoadState.Loading) return mutableUiState.update { it.copyObject(loadMoreState = LoadState.Loading) } loadMoreJob?.cancel() loadMoreJob = coroutineScope.launch { loadMoreFromServer() .onSuccess { list -> mutableUiState.update { it.copyObject( dataList = it.dataList + list, loadMoreState = LoadState.Idle, ) } }.onFailure { e -> mutableUiState.update { it.copyObject(loadMoreState = LoadState.Failed(e.toTextStringOrNull())) } } } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/coroutines/JobExt.kt ================================================ package com.zhangke.framework.coroutines import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job fun Job.invokeOnCancel(block: (CancellationException) -> Unit) { invokeOnCompletion { if (it is CancellationException) { block(it) } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/date/DateParser.kt ================================================ @file:OptIn(ExperimentalTime::class) package com.zhangke.framework.date import com.zhangke.framework.utils.Rfc822InstantParser import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlin.time.ExperimentalTime object DateParser { @OptIn(ExperimentalTime::class) fun parseOrCurrent(datetime: String): com.zhangke.framework.datetime.Instant { val instant = parseAll(datetime) ?: Clock.System.now() return com.zhangke.framework.datetime.Instant(instant) } fun parseAll(datetime: String): Instant? { return parseISODate(datetime) ?: parseRfc822Date(datetime) ?: parseRfc3339Date(datetime) ?: parseISO8601(datetime) } fun parseISODate(datetime: String): Instant? { return try { Instant.parse(datetime) } catch (e: Throwable) { null } } fun parseRfc822Date(datetime: String): Instant? { return try { Rfc822InstantParser.parse(datetime) } catch (e: Throwable) { null } } fun parseRfc3339Date(datetime: String): Instant? { return try { Instant.parse(datetime) } catch (e: Throwable) { null } } fun parseISO8601(datetime: String): Instant? { return try { Instant.parse(datetime) } catch (e: Throwable) { e.printStackTrace() null } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/date/InstantFormater.kt ================================================ @file:OptIn(ExperimentalTime::class) package com.zhangke.framework.date import kotlinx.datetime.Instant import kotlin.time.ExperimentalTime expect class InstantFormater() { fun formatToMediumDate(instant: Instant): String fun formatToMediumDateWithoutTime(instant: Instant): String } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/datetime/Instant.kt ================================================ package com.zhangke.framework.datetime import com.zhangke.framework.serialize.TimestampAsInstantSerializer import kotlinx.serialization.Serializable import kotlin.time.ExperimentalTime @Serializable(with = TimestampAsInstantSerializer::class) data class Instant(val epochMillis: Long) { @OptIn(ExperimentalTime::class) val instant: kotlinx.datetime.Instant get() = kotlinx.datetime.Instant.fromEpochMilliseconds(epochMillis) } @OptIn(ExperimentalTime::class) fun Instant(instant: kotlinx.datetime.Instant): Instant { return Instant(instant.toEpochMilliseconds()) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/imageloader/ImageLoaderUtils.kt ================================================ package com.zhangke.framework.imageloader import com.seiko.imageloader.ImageLoader import com.seiko.imageloader.model.ImageRequest import com.seiko.imageloader.model.ImageResult suspend fun ImageLoader.executeSafety(request: ImageRequest): ImageResult { return try { this.execute(request) } catch (e: Throwable) { ImageResult.OfError(e) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/ktx/CollectionsExt.kt ================================================ package com.zhangke.framework.ktx import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed fun Collection.isSingle(): Boolean = size == 1 fun List.second(): T { return this[1] } fun List.third(): T { return this[2] } fun List.fourth(): T { return this[3] } fun List.averageDropFirst(count: Int): Double { var sum = 0.0 for (index in count .. lastIndex) { sum += get(index) } return sum / count } fun Iterable.sum(): Dp { var sum: Dp = 0.dp for (element in this) { sum += element } return sum } fun List.distinctByKey(getKey: (index: Int, item: T) -> String): List { if (this.size < 2) return this val keySet = mutableSetOf() val newList = mutableListOf() this.fastForEachIndexed { index, item -> val key = getKey(index, item) if (!keySet.contains(key)) { keySet += key newList += item } } return newList } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/ktx/FlowExt.kt ================================================ package com.zhangke.framework.ktx import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn fun StateFlow.map( coroutineScope: CoroutineScope, mapper: (value: T) -> M ): StateFlow = map { mapper(it) }.stateIn( coroutineScope, SharingStarted.Eagerly, mapper(value) ) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/ktx/LazyBackingFieldDelegate.kt ================================================ package com.zhangke.framework.ktx import kotlin.reflect.KProperty class LazyBackingFieldDelegate(private val provider: () -> T) { private var backingField: T? = null operator fun getValue(thisRef: Any?, property: KProperty<*>): T { if (backingField == null) { backingField = provider() } return backingField!! } operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { backingField = value } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/ktx/StringExt.kt ================================================ package com.zhangke.framework.ktx inline fun String?.ifNullOrEmpty(block: () -> String): String { if (this == null) return block() return ifEmpty(block) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/ktx/ViewModels.kt ================================================ package com.zhangke.framework.ktx import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zhangke.framework.lifecycle.SubViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext fun ViewModel.launchInViewModel( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit ): Job { return viewModelScope.launch(context, start, block) } fun SubViewModel.launchInViewModel( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit ): Job { return viewModelScope.launch(context, start, block) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/lifecycle/ContainerViewModel.kt ================================================ package com.zhangke.framework.lifecycle import androidx.lifecycle.ViewModel abstract class ContainerViewModel : ViewModel() { abstract fun createSubViewModel(params: P): T private val subViewModelStore = mutableMapOf() protected fun obtainSubViewModel(params: P): T { return subViewModelStore.getOrPut(params.key) { createSubViewModel(params) }.also { addCloseable(it) } } abstract class SubViewModelParams { abstract val key: String } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/lifecycle/SubViewModel.kt ================================================ package com.zhangke.framework.lifecycle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlin.coroutines.CoroutineContext abstract class SubViewModel : AutoCloseable, CoroutineScope { final override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate val viewModelScope = CoroutineScope(coroutineContext) override fun close() { coroutineContext.cancel() } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/loadable/lazycolumn/LoadMoreUi.kt ================================================ package com.zhangke.framework.loadable.lazycolumn import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf 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.text.style.TextAlign import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.ScrollDirection import com.zhangke.framework.composable.rememberDirectionalLazyListState import com.zhangke.framework.composable.textString import com.zhangke.framework.utils.LoadState import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.stringResource @Composable fun LoadMoreUi( loadState: LoadState, onLoadMore: () -> Unit, ) { when (loadState) { is LoadState.Loading -> { Box( modifier = Modifier .fillMaxWidth() .padding(vertical = 24.dp) ) { CircularProgressIndicator( modifier = Modifier .size(18.dp) .align(Alignment.Center) ) } } is LoadState.Failed -> { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { var errorMessage = loadState.message?.let { textString(it) } if (errorMessage.isNullOrEmpty()) { errorMessage = stringResource(LocalizedString.loadMoreError) } Text( modifier = Modifier.fillMaxWidth(), text = errorMessage, textAlign = TextAlign.Center, ) TextButton( modifier = Modifier.padding(top = 6.dp), onClick = onLoadMore, ) { Text(text = stringResource(LocalizedString.retry)) } } } else -> {} } } @Composable fun ObserveLoadMore( lazyListState: LazyListState, onLoadMore: () -> Unit, loadMoreRemainCountThreshold: Int = 3, ) { val listLayoutInfo by remember { derivedStateOf { lazyListState.layoutInfo } } val directional = rememberDirectionalLazyListState(lazyListState).scrollDirection val totalItemsCount = listLayoutInfo.totalItemsCount var inLoadingMoreZone by remember { mutableStateOf(false) } val currentLastVisibleIndex = listLayoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 val remainToBottomCount = totalItemsCount - currentLastVisibleIndex - 1 inLoadingMoreZone = totalItemsCount > 0 && remainToBottomCount <= loadMoreRemainCountThreshold && totalItemsCount > loadMoreRemainCountThreshold LaunchedEffect(inLoadingMoreZone, directional) { if (inLoadingMoreZone) { onLoadMore() } } } @Composable fun ObserveLazyListLoadEvent( lazyListState: LazyListState, loadPreviousPageRemainCountThreshold: Int, loadMoreRemainCountThreshold: Int, onLoadPrevious: () -> Unit, onLoadMore: () -> Unit, ) { val listLayoutInfo by remember { derivedStateOf { lazyListState.layoutInfo } } val directional = rememberDirectionalLazyListState(lazyListState).scrollDirection val totalItemsCount = listLayoutInfo.totalItemsCount var inLoadPreviousZone by remember { mutableStateOf(false) } val currentFirstVisibleIndex = listLayoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0 inLoadPreviousZone = totalItemsCount > 0 && currentFirstVisibleIndex <= loadPreviousPageRemainCountThreshold && totalItemsCount > loadPreviousPageRemainCountThreshold LaunchedEffect(inLoadPreviousZone, directional) { if (inLoadPreviousZone && directional == ScrollDirection.Up) { onLoadPrevious() } } var inLoadingMoreZone by remember { mutableStateOf(false) } val currentLastVisibleIndex = listLayoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 val remainToBottomCount = totalItemsCount - currentLastVisibleIndex - 1 inLoadingMoreZone = totalItemsCount > 0 && remainToBottomCount <= loadMoreRemainCountThreshold && totalItemsCount > loadMoreRemainCountThreshold LaunchedEffect(inLoadingMoreZone, directional) { if (inLoadingMoreZone) { onLoadMore() } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/loadable/lazycolumn/LoadableInlineVideoLazyColumn.kt ================================================ package com.zhangke.framework.loadable.lazycolumn import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.zhangke.framework.blur.applyBlurSource import com.zhangke.framework.composable.LocalContentPadding import com.zhangke.framework.composable.inline.InlineVideoLazyColumn import com.zhangke.framework.utils.LoadState @Composable fun LoadableInlineVideoLazyColumn( modifier: Modifier = Modifier, state: LoadableLazyInlineVideoColumnState, refreshing: Boolean, loadState: LoadState, reverseLayout: Boolean = false, verticalArrangement: Arrangement.Vertical = if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, horizontalAlignment: Alignment.Horizontal = Alignment.Start, flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, onLoadPrevious: (() -> Unit)? = null, loadingContent: (@Composable () -> Unit)? = null, content: LazyListScope.() -> Unit, ) { val lazyListState = state.lazyListState val loadMoreStateInternal by remember(loadState) { mutableStateOf(loadState) } val loadMoreFunction by rememberUpdatedState(newValue = state.loadMoreState.onLoadMore) PullToRefreshBox( modifier = modifier, state = state.pullRefreshState.pullRefreshState, isRefreshing = refreshing, onRefresh = { state.pullRefreshState.onRefresh() }, indicator = { PullToRefreshIndicator( state = state.pullRefreshState.pullRefreshState, refreshing = refreshing, ) }, ) { InlineVideoLazyColumn( contentPadding = LocalContentPadding.current, state = state.lazyListState, modifier = Modifier.applyBlurSource(), reverseLayout = reverseLayout, verticalArrangement = verticalArrangement, horizontalAlignment = horizontalAlignment, flingBehavior = flingBehavior, userScrollEnabled = userScrollEnabled, content = { content() item { if (loadingContent != null) { loadingContent() } else { LoadMoreUi( loadState = loadMoreStateInternal, onLoadMore = loadMoreFunction, ) } } }, ) } ObserveLazyListLoadEvent( lazyListState = lazyListState, loadMoreRemainCountThreshold = state.loadMoreState.loadMoreRemainCountThreshold, loadPreviousPageRemainCountThreshold = 3, onLoadPrevious = { onLoadPrevious?.invoke() }, onLoadMore = state.loadMoreState.onLoadMore, ) } @OptIn(ExperimentalMaterialApi::class) @Composable fun rememberLoadableInlineVideoLazyColumnState( onRefresh: () -> Unit, onLoadMore: () -> Unit, loadMoreRemainCountThreshold: Int = 3, initialFirstVisibleItemIndex: Int = 0, initialFirstVisibleItemScrollOffset: Int = 0 ): LoadableLazyInlineVideoColumnState { val pullRefreshState = rememberPullToRefreshState() val pullToRefreshingState = remember(pullRefreshState, onRefresh) { PullToRefreshingState( pullRefreshState = pullRefreshState, onRefresh = onRefresh, ) } val lazyListState = rememberLazyListState( initialFirstVisibleItemScrollOffset = initialFirstVisibleItemScrollOffset, initialFirstVisibleItemIndex = initialFirstVisibleItemIndex, ) val loadMoreState = rememberLoadMoreState(loadMoreRemainCountThreshold, onLoadMore) return remember(pullRefreshState, lazyListState, loadMoreState) { LoadableLazyInlineVideoColumnState( lazyListState = lazyListState, pullRefreshState = pullToRefreshingState, loadMoreState = loadMoreState, ) } } @Composable fun rememberLoadMoreState( loadMoreRemainCountThreshold: Int, onLoadMore: () -> Unit, ): LoadMoreState { return remember { LoadMoreState(loadMoreRemainCountThreshold, onLoadMore) } } data class LoadMoreState( val loadMoreRemainCountThreshold: Int, val onLoadMore: () -> Unit, ) data class LoadableLazyInlineVideoColumnState( val lazyListState: LazyListState, val pullRefreshState: PullToRefreshingState, val loadMoreState: LoadMoreState, ) data class PullToRefreshingState( val pullRefreshState: PullToRefreshState, val onRefresh: () -> Unit, ) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/loadable/lazycolumn/LoadableLazyColumn.kt ================================================ package com.zhangke.framework.loadable.lazycolumn import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf 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.unit.dp import com.zhangke.framework.utils.LoadState @OptIn(ExperimentalMaterialApi::class) @Composable fun LoadableLazyColumn( modifier: Modifier, state: LoadableLazyColumnState, refreshing: Boolean, loadState: LoadState, lazyColumnModifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), reverseLayout: Boolean = false, verticalArrangement: Arrangement.Vertical = if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, horizontalAlignment: Alignment.Horizontal = Alignment.Start, flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, loadingContent: (@Composable () -> Unit)? = null, content: LazyListScope.() -> Unit, ) { val lazyListState = state.lazyListState val listLayoutInfo by remember { derivedStateOf { lazyListState.layoutInfo } } PullToRefreshBox( modifier = modifier, state = state.pullRefreshState.pullRefreshState, isRefreshing = refreshing, onRefresh = { state.pullRefreshState.onRefresh() }, indicator = { PullToRefreshIndicator( state = state.pullRefreshState.pullRefreshState, refreshing = refreshing, ) }, ) { LazyColumn( modifier = lazyColumnModifier, state = state.lazyListState, contentPadding = contentPadding, reverseLayout = reverseLayout, verticalArrangement = verticalArrangement, horizontalAlignment = horizontalAlignment, flingBehavior = flingBehavior, userScrollEnabled = userScrollEnabled, content = { content() item { if (loadingContent != null) { loadingContent() } else { LoadMoreUi( loadState = loadState, onLoadMore = state.loadMoreState.onLoadMore, ) } } }, ) } val currentLastVisibleIndex = listLayoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 var inLoadingMoreZone by remember { mutableStateOf(false) } val remainCount = listLayoutInfo.totalItemsCount - currentLastVisibleIndex - 1 inLoadingMoreZone = listLayoutInfo.totalItemsCount > 0 && remainCount <= state.loadMoreState.loadMoreRemainCountThreshold && listLayoutInfo.totalItemsCount > state.loadMoreState.loadMoreRemainCountThreshold if (inLoadingMoreZone) { LaunchedEffect(Unit) { state.loadMoreState.onLoadMore() } } } @OptIn(ExperimentalMaterialApi::class) @Composable fun rememberLoadableLazyColumnState( onRefresh: () -> Unit, onLoadMore: () -> Unit, loadMoreRemainCountThreshold: Int = 5, lazyListState: LazyListState = rememberLazyListState(), ): LoadableLazyColumnState { val pullRefreshState = rememberPullToRefreshState() val pullToRefreshingState = remember(pullRefreshState, onRefresh) { PullToRefreshingState( pullRefreshState = pullRefreshState, onRefresh = onRefresh, ) } val loadMoreState = rememberLoadMoreState( loadMoreRemainCountThreshold = loadMoreRemainCountThreshold, onLoadMore = onLoadMore, ) return remember(pullRefreshState, lazyListState, loadMoreState) { LoadableLazyColumnState( lazyListState = lazyListState, pullRefreshState = pullToRefreshingState, loadMoreState = loadMoreState, ) } } @OptIn(ExperimentalMaterialApi::class) data class LoadableLazyColumnState( val lazyListState: LazyListState, val pullRefreshState: PullToRefreshingState, val loadMoreState: LoadMoreState, ) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/loadable/lazycolumn/PullToRefreshIndicator.kt ================================================ package com.zhangke.framework.loadable.lazycolumn import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.zhangke.framework.composable.LocalContentPadding @Composable fun BoxScope.PullToRefreshIndicator( state: PullToRefreshState, refreshing: Boolean, ) { PullToRefreshDefaults.Indicator( state = state, isRefreshing = refreshing, modifier = Modifier.align(Alignment.TopCenter) .padding(LocalContentPadding.current.calculateTopPadding()), containerColor = MaterialTheme.colorScheme.primaryContainer, color = MaterialTheme.colorScheme.onPrimaryContainer, ) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/loadable/previous/PreviousPageLoadingState.kt ================================================ package com.zhangke.framework.loadable.previous import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.TextString import com.zhangke.fread.localization.LocalizedString import kotlinx.coroutines.flow.MutableStateFlow import org.jetbrains.compose.resources.stringResource sealed interface PreviousPageLoadingState { data object Idle : PreviousPageLoadingState data object Loading : PreviousPageLoadingState data class Failed(val errorMessage: TextString?) : PreviousPageLoadingState } data class LoadPreviousPageUiState( val onLoadPreviousPage: () -> Unit, val initialState: PreviousPageLoadingState, val loadPreviousPageThreshold: Int, ) { internal val loadingState = MutableStateFlow(initialState) fun update(state: PreviousPageLoadingState) { loadingState.value = state } } @Composable fun rememberLoadPreviousPageUiState( onLoadPreviousPage: () -> Unit, initialState: PreviousPageLoadingState = PreviousPageLoadingState.Idle, loadPreviousPageThreshold: Int = 3, ): LoadPreviousPageUiState { return remember(onLoadPreviousPage, loadPreviousPageThreshold) { LoadPreviousPageUiState( onLoadPreviousPage = onLoadPreviousPage, initialState = initialState, loadPreviousPageThreshold = loadPreviousPageThreshold, ) } } @Composable fun LoadPreviousPageItem( modifier: Modifier, state: PreviousPageLoadingState, onLoadPreviousPage: () -> Unit, ) { when (state) { is PreviousPageLoadingState.Loading -> { LoadingPreviousUi(modifier) } is PreviousPageLoadingState.Failed -> { LoadPreviousFailedUi(modifier, onLoadPreviousPage) } else -> {} } } @Composable private fun LoadingPreviousUi( modifier: Modifier = Modifier, ) { Row( modifier = modifier.padding(vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { Text( text = stringResource(LocalizedString.feedsLoadPreviousPageLabel), style = MaterialTheme.typography.labelMedium, ) Spacer(modifier = Modifier.width(6.dp)) CircularProgressIndicator( modifier = Modifier.size(24.dp) ) } } @Composable private fun LoadPreviousFailedUi( modifier: Modifier, onClick: () -> Unit, ) { Box( modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 6.dp), ) { Card( modifier = Modifier .fillMaxWidth() .clickable { onClick() }, ) { Text( modifier = Modifier .padding(vertical = 6.dp) .align(Alignment.CenterHorizontally), text = stringResource(LocalizedString.feedsLoadPreviousPageFailedLabel), style = MaterialTheme.typography.labelMedium, ) } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/module/ModuleStartup.kt ================================================ package com.zhangke.framework.module interface ModuleStartup { fun onAppCreate() } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/nav/DialogNavMetadata.kt ================================================ package com.zhangke.framework.nav import androidx.compose.ui.window.DialogProperties import androidx.navigation3.scene.DialogSceneStrategy const val FREAD_DIALOG_METADATA_KEY = "fread.dialog" fun dialogMetadata( properties: DialogProperties = DialogProperties(), ): Map { return DialogSceneStrategy.dialog(properties) + mapOf(FREAD_DIALOG_METADATA_KEY to true) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/nav/LocalNavBackStack.kt ================================================ package com.zhangke.framework.nav import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.staticCompositionLocalOf import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey val LocalNavBackStack: ProvidableCompositionLocal?> = staticCompositionLocalOf { null } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/nav/NavBackStackExt.kt ================================================ package com.zhangke.framework.nav import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey fun NavBackStack.popIfNotRoot(): Boolean { if (size <= 1) return false removeAt(lastIndex) return true } fun NavBackStack.replaceTopOrAdd(key: T) { if (isEmpty()) { add(key) return } this[lastIndex] = key } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/nav/NavEntryProvider.kt ================================================ package com.zhangke.framework.nav import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import kotlinx.serialization.modules.PolymorphicModuleBuilder interface NavEntryProvider { fun EntryProviderScope.build() fun PolymorphicModuleBuilder.polymorph() } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/nav/ScreenEventFlow.kt ================================================ package com.zhangke.framework.nav import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.receiveAsFlow class ScreenEventFlow { private val channel = Channel(capacity = Channel.BUFFERED) val flow: Flow = channel.receiveAsFlow() suspend fun emit(value: T) { channel.send(value) } fun tryEmit(value: T): Boolean { return channel.trySend(value).isSuccess } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/nav/SharedElementScope.kt ================================================ package com.zhangke.framework.nav import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Modifier import androidx.navigation3.ui.LocalNavAnimatedContentScope @OptIn(ExperimentalSharedTransitionApi::class) val LocalSharedTransitionScope = compositionLocalOf { error("No SharedElementScope provided") } @Composable fun Modifier.sharedElement(key: String): Modifier { val sharedTransitionScope = LocalSharedTransitionScope.current val animatedContentScope = LocalNavAnimatedContentScope.current return with(sharedTransitionScope) { sharedElement( sharedContentState = rememberSharedContentState(key = key), animatedVisibilityScope = animatedContentScope, ) } } @Composable fun Modifier.sharedBounds(key: String): Modifier { val sharedTransitionScope = LocalSharedTransitionScope.current val animatedContentScope = LocalNavAnimatedContentScope.current return with(sharedTransitionScope) { sharedBounds( sharedContentState = rememberSharedContentState(key = key), animatedVisibilityScope = animatedContentScope, ) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/nav/Tab.kt ================================================ package com.zhangke.framework.nav import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TabRowDefaults.primaryContainerColor import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.LocalDensity import com.zhangke.framework.composable.FreadTabRow import com.zhangke.framework.composable.LocalContentPadding import com.zhangke.framework.composable.plusTopPadding import com.zhangke.framework.utils.pxToDp import com.zhangke.framework.utils.roundToPx import com.zhangke.framework.utils.toPx import kotlinx.coroutines.launch import kotlin.math.roundToInt interface Tab { val options: TabOptions? @Composable get @Composable fun Content() } abstract class BaseTab : Tab { @Composable override fun Content() { } } data class TabOptions( val title: String, val icon: Painter? = null ) @Composable fun HorizontalPagerWithTab( tabList: List, initialPage: Int = 0, pagerUserScrollEnabled: Boolean = true, onPageChanged: ((Int) -> Unit)? = null, ) { val coroutineScope = rememberCoroutineScope() Column(modifier = Modifier.fillMaxSize()) { val pagerState = rememberPagerState(initialPage = initialPage) { tabList.size } if (onPageChanged != null) { LaunchedEffect(pagerState.currentPage) { onPageChanged(pagerState.currentPage) } } FreadTabRow( modifier = Modifier.fillMaxWidth(), selectedTabIndex = pagerState.currentPage, tabCount = tabList.size, tabContent = { Text( text = tabList[it].options?.title.orEmpty(), maxLines = 1, ) }, onTabClick = { coroutineScope.launch { pagerState.scrollToPage(it) } } ) HorizontalPager( modifier = Modifier.fillMaxSize(), state = pagerState, userScrollEnabled = pagerUserScrollEnabled, ) { pageIndex -> with(tabList[pageIndex]) { Content() } } } } @Composable fun ContentPaddingsHorizontalPagerWithTab( tabList: List, modifier: Modifier = Modifier, initialPage: Int = 0, blurEnabled: Boolean = true, pagerUserScrollEnabled: Boolean = true, containerColor: Color = MaterialTheme.colorScheme.surface, onPageChanged: ((Int) -> Unit)? = null, ) { val coroutineScope = rememberCoroutineScope() val density = LocalDensity.current val pagerState = rememberPagerState(initialPage = initialPage) { tabList.size } if (onPageChanged != null) { LaunchedEffect(pagerState.currentPage) { onPageChanged(pagerState.currentPage) } } val topOffset = LocalContentPadding.current.calculateTopPadding().roundToPx() SubcomposeLayout(modifier = modifier) { constraints -> val tabRowPlaceable = subcompose("tabRow") { FreadTabRow( modifier = Modifier.fillMaxWidth(), selectedTabIndex = pagerState.currentPage, tabCount = tabList.size, containerColor = containerColor, blurEffectEnabled = blurEnabled, tabContent = { Text( text = tabList[it].options?.title.orEmpty(), maxLines = 1, ) }, onTabClick = { coroutineScope.launch { pagerState.scrollToPage(it) } }, ) }.first().measure(constraints) val tabRowHeightDp = tabRowPlaceable.height.pxToDp(density) val pagerPlaceable = subcompose("pager") { HorizontalPager( modifier = Modifier.fillMaxSize(), state = pagerState, userScrollEnabled = pagerUserScrollEnabled, ) { pageIndex -> CompositionLocalProvider( LocalContentPadding provides plusTopPadding(tabRowHeightDp) ) { with(tabList[pageIndex]) { Content() } } } }.first().measure(constraints) layout(constraints.maxWidth, constraints.maxHeight) { pagerPlaceable.placeRelative(0, 0) tabRowPlaceable.placeRelative(0, topOffset) } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/network/FormalBaseUrl.kt ================================================ package com.zhangke.framework.network import com.zhangke.framework.utils.Parcelize import com.zhangke.framework.utils.PlatformParcelable import com.zhangke.framework.utils.PlatformSerializable import com.zhangke.framework.utils.UrlEncoder import com.zhangke.framework.utils.uriString import kotlinx.serialization.Serializable @Parcelize @Serializable class FormalBaseUrl private constructor( val scheme: String, val host: String, ) : PlatformParcelable, PlatformSerializable { override fun toString(): String { return "$scheme$SCHEME_SEPARATOR$host" } override fun hashCode(): Int { var result = scheme.hashCode() result = 31 * result + host.hashCode() return result } override fun equals(other: Any?): Boolean { if (other === this) return true if (other == null) return false if (other !is FormalBaseUrl) return false return (other.scheme == scheme) && (other.host == host) } fun equalsDomain(other: FormalBaseUrl): Boolean { if (this == other) return true if (this.host.endsWith(other.host)) return true if (other.host.endsWith(this.host)) return true return false } /** * Not encode string */ fun toRawString(): String { return uriString( scheme = scheme, host = host, path = "", queries = emptyMap(), encode = false, ) } companion object { private const val SCHEME_SEPARATOR = "://" fun build(scheme: String, host: String): FormalBaseUrl { return FormalBaseUrl(scheme, host) } fun parse(string: String): FormalBaseUrl? { val url = SimpleUri.parse(string.addProtocolIfNecessary()) ?: return null val scheme = url.scheme?.lowercase() ?: return null if (scheme !in arrayOf("http", "https")) return null val host = url.host ?: return null if (host.isEmpty()) return null return FormalBaseUrl(scheme, host.removeHostSuffix()) } private fun String.removeHostSuffix(): String { return this.removeSuffix("/") } } } fun FormalBaseUrl.encode(): String { return UrlEncoder.encode(toRawString()) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/network/GlobalRoutes.kt ================================================ package com.zhangke.framework.network object GlobalRoutes { private const val SCHEME = "fread" const val ROOT_PREFIX = "$SCHEME://" } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/network/HttpScheme.kt ================================================ package com.zhangke.framework.network object HttpScheme { const val HTTP = "http://" const val HTTPS = "https://" fun validate(scheme: String): Boolean { val fixedScheme = scheme.lowercase() return fixedScheme == HTTP || fixedScheme == HTTPS } } fun String.addProtocolIfNecessary(): String { if (this.contains("://")) return this return "${HttpScheme.HTTPS}$this" } fun String.addProtocolSuffixIfNecessary(): String { if (this.endsWith("://")) return this return "${this}://" } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/network/SimpleUri.kt ================================================ package com.zhangke.framework.network import com.zhangke.framework.utils.toPlatformUri import com.zhangke.framework.utils.uriString data class SimpleUri( val scheme: String?, val host: String?, val path: String?, val queries: Map, ) { override fun toString(): String { return uriString( scheme = scheme.orEmpty(), host = host.orEmpty(), path = path.orEmpty(), queries = queries, ) } companion object { fun parse(uri: String): SimpleUri? { if (uri.isEmpty()) return null val formalUri = try { uri.toPlatformUri() } catch (_: Throwable) { null } ?: return null val scheme = formalUri.scheme val host = formalUri.host val path = formalUri.path val queries = runCatching { formalUri.getQueryParameterNames().associateWith { formalUri.getQueryParameter(it).orEmpty() } }.getOrNull() ?: return null return SimpleUri(scheme, host, path, queries) } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/opml/OpmlOutline.kt ================================================ package com.zhangke.framework.opml data class OpmlOutline( val title: String, val xmlUrl: String, val children: List, ) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/opml/OpmlParser.kt ================================================ package com.zhangke.framework.opml import com.fleeksoft.ksoup.nodes.Node import com.fleeksoft.ksoup.parser.XmlTreeBuilder import com.zhangke.framework.ktx.ifNullOrEmpty object OpmlParser { fun parse(xml: String): List { val outlineList = mutableListOf() val outlineStack = mutableListOf() fun parserNode(node: Node) { if (node.nodeName() == "outline") { val text = node.attr("text") val title = node.attr("title") val xmlUrl = node.attr("xmlUrl") val htmlUrl = node.attr("htmlUrl") val outline = Outline( title = title, text = text, xmlUrl = xmlUrl, htmlUrl = htmlUrl, children = mutableListOf(), ) if (outlineStack.isEmpty()) { outlineList.add(outline) } else { outlineStack.last().children.add(outline) } if (node.childNodes().isNotEmpty()) { outlineStack.add(outline) node.childNodes().forEach { parserNode(it) } outlineStack.removeAt(outlineStack.size - 1) } } else if (node.childNodes().isNotEmpty()) { node.childNodes().forEach { parserNode(it) } } } val doc = XmlTreeBuilder().parse(xml) parserNode(doc) return outlineList.map { it.toOpmlOutline() } } private fun Outline.toOpmlOutline(): OpmlOutline { return OpmlOutline( title = text.ifNullOrEmpty { title }, xmlUrl = xmlUrl, children = children.map { it.toOpmlOutline() }, ) } private class Outline( val title: String, val text: String, val xmlUrl: String, val htmlUrl: String?, val children: MutableList, ) { override fun toString(): String { return "Outline(title='$title', text='$text', xmlUrl='$xmlUrl', htmlUrl=$htmlUrl, children=$children)" } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/permission/RequireLocalStoragePermission.kt ================================================ package com.zhangke.framework.permission import androidx.compose.runtime.Composable @Composable expect fun RequireLocalStoragePermission( onPermissionGranted: suspend () -> Unit, onPermissionDenied: suspend (() -> Unit) = {}, ) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/security/Md5.kt ================================================ package com.zhangke.framework.security import io.ktor.utils.io.core.toByteArray import okio.ByteString object Md5 { fun md5(input: String): String { return ByteString.of(*input.toByteArray()).md5().hex() } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/serialize/TimestampAsInstantSerializer.kt ================================================ @file:OptIn(ExperimentalTime::class) package com.zhangke.framework.serialize import com.zhangke.framework.datetime.Instant import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlin.time.ExperimentalTime object TimestampAsInstantSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("kotlinx.datetime.Instant", PrimitiveKind.LONG) override fun deserialize(decoder: Decoder): Instant { val timestamp = decoder.decodeLong() return Instant(kotlinx.datetime.Instant.fromEpochMilliseconds(timestamp)) } override fun serialize(encoder: Encoder, value: Instant) { encoder.encodeLong(value.instant.toEpochMilliseconds()) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/toast/Toast.kt ================================================ package com.zhangke.framework.toast expect fun toast(message: String?) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/AspectRatio.kt ================================================ package com.zhangke.framework.utils import kotlinx.serialization.Serializable @Parcelize @Serializable data class AspectRatio( val width: Long, val height: Long, ) : PlatformSerializable, PlatformParcelable { val ratio: Float get() = width.toFloat() / height init { require(width >= 1) { "width must be >= 1, but was $width" } require(height >= 1) { "height must be >= 1, but was $height" } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/BlendColorUtils.kt ================================================ package com.zhangke.framework.utils import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import kotlin.math.pow import kotlin.math.round object BlendColorUtils { fun blend( fraction: Float, startColor: Color, endColor: Color, ): Color { val startInt: Int = startColor.toArgb() val startA = ((startInt shr 24) and 0xff) / 255.0f var startR = ((startInt shr 16) and 0xff) / 255.0f var startG = ((startInt shr 8) and 0xff) / 255.0f var startB = (startInt and 0xff) / 255.0f val endInt: Int = endColor.toArgb() val endA = ((endInt shr 24) and 0xff) / 255.0f var endR = ((endInt shr 16) and 0xff) / 255.0f var endG = ((endInt shr 8) and 0xff) / 255.0f var endB = (endInt and 0xff) / 255.0f // convert from sRGB to linear startR = startR.pow(2.2f) startG = startG.pow(2.2f) startB = startB.pow(2.2f) endR = endR.pow(2.2f) endG = endG.pow(2.2f) endB = endB.pow(2.2f) // compute the interpolated color in linear space var a = startA + fraction * (endA - startA) var r = startR + fraction * (endR - startR) var g = startG + fraction * (endG - startG) var b = startB + fraction * (endB - startB) // convert back to sRGB in the [0..255] range a *= 255.0f r = r.pow(1.0f / 2.2f) * 255.0f g = g.pow(1.0f / 2.2f) * 255.0f b = b.pow(1.0f / 2.2f) * 255.0f return Color( round(r).toInt(), round(g).toInt(), round(b).toInt(), round(a).toInt(), ) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/ContentProviderFile.kt ================================================ package com.zhangke.framework.utils data class ContentProviderFile( val uri: PlatformUri, val fileName: String, val size: StorageSize, val mimeType: String, private val streamProvider: () -> ByteArray?, ) { fun readBytes(): ByteArray? { return streamProvider() } val isVideo: Boolean get() = mimeType.contains("video") } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/DebugUtils.kt ================================================ package com.zhangke.framework.utils import com.zhangke.framework.toast.toast var appDebuggable = false private set fun initDebuggable(debug: Boolean) { appDebuggable = debug } inline fun ifDebugging(block: () -> Unit) { if (appDebuggable) { block() } } fun throwInDebug(message: String?, throwable: Throwable? = null) { if (appDebuggable) { toast("Non-fatal error! ${message ?: throwable?.message}") if (throwable != null) throw throwable throw ThrowInDebugException(message) } else { // TODO report to server } } class ThrowInDebugException(message: String?) : RuntimeException(message) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/DensityUtils.kt ================================================ package com.zhangke.framework.utils import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import kotlin.math.roundToInt @Composable fun Dp.toPx(): Float { return with(LocalDensity.current) { toPx() } } @Composable fun Dp.roundToPx(): Int { return with(LocalDensity.current) { toPx().roundToInt() } } fun Dp.dpToPx(density: Density): Float { return with(density) { toPx() } } fun Int.dpToPx(density: Density): Float { return Dp(this.toFloat()).dpToPx(density) } fun Int.pxToDp(density: Density): Dp { val pxValue = this return with(density) { pxValue.toDp() } } fun Float.pxToDp(density: Density): Dp { val pxValue = this return with(density) { pxValue.toDp() } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/DomainValidator.kt ================================================ package com.zhangke.framework.utils object DomainValidator { fun validate(domain: String): Boolean { if (domain.isEmpty()) return false try { if (RegexFactory.domainRegex.matches(domain)) return true val idnDomain = IDNUtils().toASCII(domain) return RegexFactory.domainRegex.matches(idnDomain) } catch (_: Throwable) { return false } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/DurationFormatUtils.kt ================================================ package com.zhangke.framework.utils import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.getString import kotlin.time.Duration import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes suspend fun Duration.formattedString(): String { val builder = StringBuilder() val formattedDuration = format() val weeks = dayToWeek(formattedDuration.days) if (weeks > 0) { builder.append("$weeks${getString(LocalizedString.durationWeek)}") } val days = (formattedDuration.days - weeks * 7).coerceAtLeast(0) if (days > 0) { if (builder.isNotEmpty()) { builder.append(" ") } builder.append("$days${getString(LocalizedString.durationDay)}") } if (formattedDuration.hours > 0) { if (builder.isNotEmpty()) { builder.append(" ") } builder.append("${formattedDuration.hours}${getString(LocalizedString.durationHour)}") } if (formattedDuration.minutes > 0) { if (builder.isNotEmpty()) { builder.append(" ") } builder.append("${formattedDuration.minutes}${getString(LocalizedString.durationMinute)}") } return builder.toString() } fun dayToWeek(day: Int): Int { return day / 7 } fun Duration.format(): FormattedDuration { var leftDuration = this val days = leftDuration.inWholeDays.toInt() leftDuration -= days.days val hours = leftDuration.inWholeHours.toInt() leftDuration -= hours.hours val minutes = leftDuration.inWholeMinutes.toInt() leftDuration -= minutes.minutes return FormattedDuration(days = days, hours = hours, minutes = minutes) } data class FormattedDuration( val days: Int, val hours: Int, val minutes: Int, ) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/ExtractUrlFromTextUtils.kt ================================================ package com.zhangke.framework.utils object ExtractUrlFromTextUtils { private val urlRegex = """ (?:https?://)?(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}(?!\.[A-Za-z0-9-])(?=$|[:/?#]|[^\w-])(?::\d{2,5})?(?:[/?#][^\s'"]*)? """.trimIndent().toRegex() private val trailingPunctuation = setOf('.', ',', '!', '?', ':', ';', '"', '\'', '”', '’') private val closingToOpeningBracket = mapOf(')' to '(', ']' to '[', '}' to '{') fun extract(text: String): List { if (text.isEmpty()) return emptyList() return urlRegex.findAll(text) .mapNotNull { sanitize(it.value) } .toList() } private fun sanitize(candidate: String): String? { var url = candidate while (url.isNotEmpty() && shouldTrimLastChar(url)) { url = url.dropLast(1) } return url.takeIf { it.isNotEmpty() && urlRegex.matchEntire(it) != null } } private fun shouldTrimLastChar(url: String): Boolean { val lastChar = url.last() if (lastChar in trailingPunctuation) return true val openingBracket = closingToOpeningBracket[lastChar] ?: return false return url.count { it == lastChar } > url.count { it == openingBracket } } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/FloatExt.kt ================================================ package com.zhangke.framework.utils import kotlin.math.abs fun Float.equalsExactly(target: Float): Boolean { return abs(target - this) <= 0.000001F } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/Handle.kt ================================================ package com.zhangke.framework.utils fun String.prettyHandle(): String = if (this.startsWith('@')) this else "@$this" ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/HighlightTextBuildUtil.kt ================================================ package com.zhangke.framework.utils import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.TextUnit object HighlightTextBuildUtil { const val HIGHLIGHT_START_SYMBOL = "[[" const val HIGHLIGHT_END_SYMBOL = "]]" /** * some text[[high light text]]end text */ fun buildHighlightText( text: String, fontWeight: FontWeight? = null, highLightColor: Color? = null, highLightSize: TextUnit? = null, ): AnnotatedString { return buildAnnotatedString { var leftText = text while (leftText.isNotEmpty()) { val (prefix, highlight, suffix) = findFirstHighlightRange(leftText) if (prefix.isNotEmpty()) { append(prefix) } if (highlight.isNotEmpty()) { withStyle( style = SpanStyle( color = highLightColor ?: Color.Unspecified, fontSize = highLightSize ?: TextUnit.Unspecified, fontWeight = fontWeight, ), ) { append(highlight) } } leftText = suffix } } } private fun findFirstHighlightRange(text: String): Triple { val start = text.indexOf(HIGHLIGHT_START_SYMBOL) if (start == -1) return Triple(text, "", "") val end = text.indexOf(HIGHLIGHT_END_SYMBOL, start) if (end == -1) { return Triple(text, "", "") } val prefix = text.substring(0, start) val highlight = text.substring(start + HIGHLIGHT_START_SYMBOL.length, end) val suffix = text.substring(end + HIGHLIGHT_END_SYMBOL.length) return Triple(prefix, highlight, suffix) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/IDNUtils.kt ================================================ package com.zhangke.framework.utils expect class IDNUtils() { fun toASCII(input: String): String } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/ImageCompressUtils.kt ================================================ package com.zhangke.framework.utils expect class ImageCompressUtils() { fun compress(bytes: ByteArray, targetSize: StorageSize): CompressResult } data class CompressResult( val bytes: ByteArray, val ratio: AspectRatio?, ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || this::class != other::class) return false other as CompressResult if (!bytes.contentEquals(other.bytes)) return false if (ratio != other.ratio) return false return true } override fun hashCode(): Int { var result = bytes.contentHashCode() result = 31 * result + ratio.hashCode() return result } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/IntExt.kt ================================================ package com.zhangke.framework.utils import com.ionspin.kotlin.bignum.decimal.toBigDecimal fun Long.formatToHumanReadable(): String { return if (this >= 1_000_000) { (this / 1_000_000F).decimal(1) .removeSuffix(".0") .plus("M") } else if (this >= 1000) { (this / 1000F).decimal(1) .removeSuffix(".0") .plus("K") } else { this.toString() } } fun Int.formatToHumanReadable(): String { return this.toLong().formatToHumanReadable() } fun Float.decimal(digits: Int): String { return this.toBigDecimal() .scale(digits.toLong()) .toStringExpanded() } fun Double.decimal(digits: Int): String { return this.toBigDecimal() .scale(digits.toLong()) .toStringExpanded() } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/LanguageUtils.kt ================================================ package com.zhangke.framework.utils expect object LanguageUtils { fun getAllLanguages(): List } expect class Locale expect val Locale.languageCode: String expect val Locale.isO3LanguageCode: String expect fun Locale.getDisplayName(displayLocale: Locale): String expect fun initLocale(language: String): Locale expect fun getDefaultLocale(): Locale ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/LinkPreviewUtils.kt ================================================ package com.zhangke.framework.utils import com.fleeksoft.ksoup.Ksoup import com.fleeksoft.ksoup.nodes.Document object LinkPreviewUtils { fun fetchPreviewInfo(url: String, html: String): LinkPreviewInfo? { val document = runCatching { Ksoup.parse(html) }.getOrNull() ?: return null val pageUrl = resolveUrl( rawUrl = document.selectFirst("base[href]")?.attr("href"), articleUrl = url, ) ?: url val previewUrl = resolveUrl( rawUrl = firstNotBlank( document.metaContent("meta[property=og:url]"), document.metaContent("meta[name=og:url]"), document.linkHref("link[rel=canonical]"), document.linkHref("link[rel=alternate][hreflang=x-default]"), ), articleUrl = pageUrl, ) ?: url val title = firstNotBlank( document.metaContent("meta[property=og:title]"), document.metaContent("meta[name=og:title]"), document.metaContent("meta[name=twitter:title]"), document.metaContent("meta[property=twitter:title]"), document.title().normalizeText(), document.selectFirst("h1")?.text().normalizeText(), ) ?: return null val description = firstNotBlank( document.metaContent("meta[property=og:description]"), document.metaContent("meta[name=og:description]"), document.metaContent("meta[name=twitter:description]"), document.metaContent("meta[property=twitter:description]"), document.metaContent("meta[name=description]"), document.metaContent("meta[property=description]"), ) val image = resolveUrl( rawUrl = firstNotBlank( document.metaContent("meta[property=og:image]"), document.metaContent("meta[property=og:image:url]"), document.metaContent("meta[name=twitter:image]"), document.metaContent("meta[property=twitter:image]"), document.metaContent("meta[itemprop=image]"), document.linkHref("link[rel=image_src]"), document.linkHref("link[rel=apple-touch-icon]"), ), articleUrl = pageUrl, ) val siteName = firstNotBlank( document.metaContent("meta[property=og:site_name]"), document.metaContent("meta[name=application-name]"), document.metaContent("meta[name=apple-mobile-web-app-title]"), ) return LinkPreviewInfo( title = title, description = description, image = image, url = previewUrl, siteName = siteName, ) } private fun Document.metaContent(selector: String): String? { return selectFirst(selector) ?.attr("content") .normalizeText() } private fun Document.linkHref(selector: String): String? { return selectFirst(selector) ?.attr("href") .normalizeText() } private fun String?.normalizeText(): String? { return this ?.replace(whitespaceRegex, " ") ?.trim() ?.takeIf { it.isNotEmpty() } } private fun firstNotBlank(vararg values: String?): String? { return values.firstNotNullOfOrNull { it.normalizeText() } } private fun resolveUrl(rawUrl: String?, articleUrl: String?): String? { val value = rawUrl.normalizeText() ?: return null if (value.startsWith("data:", ignoreCase = true)) return null if (value.startsWith("blob:", ignoreCase = true)) return null if (value.startsWith("javascript:", ignoreCase = true)) return null if (value.startsWith("http://", ignoreCase = true) || value.startsWith("https://", ignoreCase = true) ) { return value } if (value.startsWith("//")) { val scheme = articleUrl ?.substringBefore("://", "") ?.takeIf { it.isNotBlank() } ?: "https" return "$scheme:$value" } val baseUrl = articleUrl ?.takeIf { it.startsWith("http://", ignoreCase = true) || it.startsWith("https://", ignoreCase = true) } ?: return null val origin = baseUrl.origin() ?: return null if (value.startsWith("/")) { return origin + normalizePath(value) } val sanitizedBase = baseUrl.substringBefore('#').substringBefore('?') val directory = if (sanitizedBase.endsWith("/")) { sanitizedBase } else { sanitizedBase.substringBeforeLast("/", "$origin/") } val relativePath = directory.substringAfter(origin, "/").trimEnd('/') val normalizedPath = normalizePath("$relativePath/$value") return origin + normalizedPath } private fun String.origin(): String? { val schemeIndex = indexOf("://") if (schemeIndex <= 0) return null val hostStart = schemeIndex + 3 val hostEnd = indexOf('/', startIndex = hostStart).takeIf { it >= 0 } ?: length return substring(0, hostEnd) } private fun normalizePath(pathWithQuery: String): String { val fragment = pathWithQuery.substringAfter('#', "") val pathWithoutFragment = pathWithQuery.substringBefore('#') val query = pathWithoutFragment.substringAfter('?', "") val rawPath = pathWithoutFragment.substringBefore('?') val stack = mutableListOf() rawPath.split('/').forEach { segment -> when (segment) { "", "." -> Unit ".." -> if (stack.isNotEmpty()) { stack.removeAt(stack.lastIndex) } else -> stack += segment } } val normalizedPath = "/" + stack.joinToString("/") return buildString { append(normalizedPath) if (query.isNotEmpty()) { append('?') append(query) } if (fragment.isNotEmpty()) { append('#') append(fragment) } } } } private val whitespaceRegex = "\\s+".toRegex() data class LinkPreviewInfo( val title: String, val description: String?, val image: String?, val url: String, val siteName: String?, ) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/LoadState.kt ================================================ package com.zhangke.framework.utils import com.zhangke.framework.composable.TextString sealed interface LoadState { val loading: Boolean get() = this is Loading data object Idle : LoadState data object Loading : LoadState data class Failed(val message: TextString?) : LoadState } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/Log.kt ================================================ package com.zhangke.framework.utils import co.touchlab.kermit.Logger import co.touchlab.kermit.loggerConfigInit import co.touchlab.kermit.platformLogWriter object Log { val log = Logger( loggerConfigInit(platformLogWriter()), "Fread", ) inline fun d(tag: String, message: () -> String) { log.d(tag = tag, message = message) } inline fun i(tag: String, message: () -> String) { log.i(tag = tag, message = message) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/Parcelize.kt ================================================ package com.zhangke.framework.utils @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.BINARY) annotation class Parcelize ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/PlatformIgnoredOnParcel.kt ================================================ package com.zhangke.framework.utils expect annotation class PlatformIgnoredOnParcel() ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/PlatformParcelable.kt ================================================ package com.zhangke.framework.utils expect interface PlatformParcelable ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/PlatformSerializable.kt ================================================ package com.zhangke.framework.utils expect interface PlatformSerializable ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/PlatformTransient.kt ================================================ package com.zhangke.framework.utils @Target(AnnotationTarget.PROPERTY) expect annotation class PlatformTransient() ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/PlatformUri.kt ================================================ package com.zhangke.framework.utils import com.eygraber.uri.Uri typealias PlatformUri = Uri fun String.toPlatformUri(): PlatformUri { return Uri.parse(this) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/RegexFactory.kt ================================================ package com.zhangke.framework.utils object RegexFactory { val domainRegex = "^(?=^.{3,255}$)[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+$".toRegex() val didRegex = "^did:[a-z0-9]+:[a-zA-Z0-9._%-]+$".toRegex() } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/ResultExt.kt ================================================ package com.zhangke.framework.utils fun List>>.collect(): Result> { if (isNotEmpty() && any { it.isSuccess }.not()) { return first() } return mapNotNull { it.getOrNull() }.reduce { list1, list2 -> mutableListOf().apply { addAll(list1) addAll(list2) } }.let { Result.success(it) } } fun Result.exceptionOrThrow(): Throwable { return exceptionOrNull() ?: throw IllegalStateException("Result is success!") } fun Result<*>.ignoreContent(): Result = map {} ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/Rfc822InstantParser.kt ================================================ @file:OptIn(ExperimentalTime::class) package com.zhangke.framework.utils import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.UtcOffset import kotlinx.datetime.asTimeZone import kotlinx.datetime.toDeprecatedInstant import kotlinx.datetime.toInstant import kotlin.time.ExperimentalTime /** * RFC 822 date/time format to [Instant] parser. * * See https://www.rfc-editor.org/rfc/rfc822#section-5 */ object Rfc822InstantParser { private enum class Month { Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec, ; } /** * Parse RFC 822 date/time format to [Instant] * * @throws IllegalArgumentException in case input string cannot be parsed as RFC 822 */ fun parse(input: String): Instant { /** * Groups: * * 1 = day of month (1-31) * 2 = month (Jan, Feb, Mar, ...) * 3 = year (2022) * 4 = hour (00-23) * 5 = minute (00-59) * 6 = OPTIONAL: second (00-59) * 7 = time zone (+/-hhmm or letters) */ val regex = Regex("^(?:\\w{3}, )?(\\d{1,2}) (\\w{3}) (\\d{4}) (\\d{2}):(\\d{2})(?::(\\d{2}))? ([+-]?\\w+)\$") val result = regex.matchEntire(input) if (result == null || result.groups.size != 8) { throw IllegalArgumentException("Unexpected RFC 822 date/time format") } try { val dayOfMonth = result.groupValues[1].toInt() val month = Month.valueOf(result.groupValues[2]) val year = result.groupValues[3].toInt() val hour = result.groupValues[4].toInt() val minute = result.groupValues[5].toInt() val second = result.groupValues[6].ifEmpty { "00" }.toInt() val timeZone = result.groupValues[7] val dateTime = LocalDateTime( year = year, monthNumber = month.ordinal + 1, dayOfMonth = dayOfMonth, hour = hour, minute = minute, second = second, nanosecond = 0, ) val tz = parseTimeZone(timeZone) return dateTime.toInstant(tz).toDeprecatedInstant() } catch (e: Exception) { throw IllegalArgumentException("Unexpected RFC 822 date/time format", e) } } /** * @see parse */ operator fun invoke(input: String): Instant = parse(input) private fun parseTimeZone(timeZone: String): TimeZone { val startsWithPlus = timeZone.startsWith('+') val startsWithMinus = timeZone.startsWith('-') val (hours, minutes) = when { startsWithPlus || startsWithMinus -> { val hour = timeZone.substring(1..2).toInt() val minute = timeZone.substring(3..4).toInt() if (startsWithMinus) { Pair(-hour, -minute) } else { Pair(hour, minute) } } // Time zones else -> when (timeZone) { "Z", "UT", "GMT" -> 0.hours "EST" -> (-5).hours "EDT" -> (-4).hours "CST" -> (-6).hours "CDT" -> (-5).hours "MST" -> (-7).hours "MDT" -> (-6).hours "PST" -> (-8).hours "PDT" -> (-7).hours // Military "A" -> (-1).hours "M" -> (-12).hours "N" -> 1.hours "Y" -> 12.hours else -> throw IllegalArgumentException("Unexpected time zone format") } } val offset = UtcOffset(hours = hours, minutes = minutes, seconds = 0) return offset.asTimeZone() } private val Int.hours get() = Pair(this, 0) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/SizeUtils.kt ================================================ package com.zhangke.framework.utils import androidx.compose.ui.geometry.Size import androidx.compose.ui.unit.Constraints fun Constraints.asSize(): Size { return Size( width = maxWidth.toFloat(), height = maxHeight.toFloat(), ) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/Standard.kt ================================================ package com.zhangke.framework.utils inline fun T.maybe(predication: Boolean, block: (T) -> T): T { return if (predication) block(this) else this } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/StorageSize.kt ================================================ package com.zhangke.framework.utils import kotlin.jvm.JvmInline @Suppress("PropertyName") @JvmInline value class StorageSize(val bytes: Long) { val KB: Double get() = bytes.toDouble() / 1024 val MB: Double get() = KB / 1024 val GB: Double get() = MB / 1024 operator fun compareTo(other: StorageSize): Int = bytes.compareTo(other.bytes) } val Int.KB: StorageSize get() = StorageSize(this.toLong() * 1024) val Int.MB: StorageSize get() = StorageSize(this.toLong() * 1024 * 1024) val Int.GB: StorageSize get() = StorageSize(this.toLong() * 1024 * 1024 * 1024) val StorageSize.prettyString: String get() { if (GB >= 1) { return "${GB.decimal(2)} GB" } if (MB >= 1) { return "${MB.decimal(2)} MB" } return "${KB.decimal(2)} KB" } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/TextFieldUtils.kt ================================================ package com.zhangke.framework.utils import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue object TextFieldUtils { fun insertText(value: TextFieldValue, insertText: String): TextFieldValue { val selection = value.selection val oldText = value.text if (oldText.isEmpty()) { return TextFieldValue(insertText, TextRange(insertText.length)) } val selectedCount = (selection.end - selection.start).coerceAtLeast(0) if (selectedCount == 0) { val charList = oldText.toMutableList() val newTextList = insertText.toList() charList.addAll(selection.start, newTextList) val newText = charList.joinToString("") return TextFieldValue(newText, TextRange(selection.start + newTextList.size)) } val charList = oldText.toMutableList() repeat(selectedCount) { charList.removeAt(selection.start) } val newTextField = TextFieldValue(charList.joinToString(""), TextRange(selection.start)) return insertText(newTextField, insertText) } } val TextFieldDefaults.transparentIndicatorColors: TextFieldColors @Composable get() = colors( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, disabledIndicatorColor = Color.Transparent, errorIndicatorColor = Color.Transparent, ) val TextFieldDefaults.transparentIndicatorAndContainerColors: TextFieldColors @Composable get() = colors( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, disabledIndicatorColor = Color.Transparent, errorIndicatorColor = Color.Transparent, errorContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, disabledContainerColor = Color.Transparent, ) ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/ThrowableUtils.kt ================================================ package com.zhangke.framework.utils fun Throwable.mapForMessage(newMessage: String): Throwable { return if (message.isNullOrEmpty()) { val typeName = this::class.simpleName RuntimeException("$newMessage-$typeName", this) } else { this } } fun Result.mapForErrorMessage(newErrorMessage: String): Result { if (this.isSuccess) return this val exception = this.exceptionOrNull() ?: return Result.failure(RuntimeException(newErrorMessage)) return Result.failure(exception.mapForMessage(newErrorMessage)) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/UriUtils.kt ================================================ package com.zhangke.framework.utils fun uriString( scheme: String, host: String, path: String, queries: Map, encode: Boolean = true, ): String { val builder = StringBuilder() if (scheme.isNotEmpty()) { builder.append(scheme) builder.append("://") } builder.append(host) var fixedPath = path if (builder.endsWith("/") && path.startsWith("/")) { fixedPath = fixedPath.removePrefix("/") } if (fixedPath.endsWith("/") && queries.isNotEmpty()) { fixedPath = fixedPath.removeSuffix("/") } builder.append(fixedPath) if (queries.isNotEmpty()) { val query = queries.entries .joinToString(prefix = "?", separator = "&") { val value = if (encode) { UrlEncoder.encode(it.value) } else { it.value } "${it.key}=$value" } builder.append(query) } return builder.toString() } fun String.decodeAsUri(): String { return UrlEncoder.decode(this) } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/UrlEncoder.kt ================================================ package com.zhangke.framework.utils import kotlin.Char.Companion.MIN_HIGH_SURROGATE import kotlin.Char.Companion.MIN_LOW_SURROGATE /** * Most defensive approach to URL encoding and decoding. * * - Rules determined by combining the unreserved character set from * [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#page-13) with the percent-encode set from * [application/x-www-form-urlencoded](https://url.spec.whatwg.org/#application-x-www-form-urlencoded-percent-encode-set). * * - Both specs above support percent decoding of two hexadecimal digits to a binary octet, however their unreserved * set of characters differs and `application/x-www-form-urlencoded` adds conversion of space to `+`, which has the * potential to be misunderstood. * * - This library encodes with rules that will be decoded correctly in either case. * * @author Geert Bevin (gbevin(remove) at uwyn dot com) * @author Erik C. Thauvin (erik@thauvin.net) **/ object UrlEncoder { private val hexDigits = "0123456789ABCDEF".toCharArray() /** * A [BooleanArray] with entries for the [character codes][Char.code] of * * * `0-9`, * * `A-Z`, * * `a-z` * * set to `true`. */ private val unreservedChars = BooleanArray('z'.code + 1).apply { set('-'.code, true) set('.'.code, true) set('_'.code, true) for (c in '0'..'9') { set(c.code, true) } for (c in 'A'..'Z') { set(c.code, true) } for (c in 'a'..'z') { set(c.code, true) } } // see https://www.rfc-editor.org/rfc/rfc3986#page-13 // and https://url.spec.whatwg.org/#application-x-www-form-urlencoded-percent-encode-set private fun Char.isUnreserved(): Boolean { return this <= 'z' && unreservedChars[code] } private fun StringBuilder.appendEncodedDigit(digit: Int) { this.append(hexDigits[digit and 0x0F]) } private fun StringBuilder.appendEncodedByte(ch: Int) { this.append("%") this.appendEncodedDigit(ch shr 4) this.appendEncodedDigit(ch) } /** * Transforms a provided [String] into a new string, containing decoded URL characters in the UTF-8 * encoding. */ fun decode(source: String, plusToSpace: Boolean = false): String { if (source.isEmpty()) { return source } val length = source.length val out = StringBuilder(length) var bytesBuffer: ByteArray? = null var bytesPos = 0 var i = 0 var started = false while (i < length) { val ch = source[i] if (ch == '%') { if (!started) { out.append(source, 0, i) started = true } if (bytesBuffer == null) { // the remaining characters divided by the length of the encoding format %xx, is the maximum number // of bytes that can be extracted bytesBuffer = ByteArray((length - i) / 3) } i++ require(length >= i + 2) { "Incomplete trailing escape ($ch) pattern" } try { val v = source.substring(i, i + 2).toInt(16) require(v in 0..0xFF) { "Illegal escape value" } bytesBuffer[bytesPos++] = v.toByte() i += 2 } catch (e: NumberFormatException) { throw IllegalArgumentException( "Illegal characters in escape sequence: $e.message", e ) } } else { if (bytesBuffer != null) { out.append(bytesBuffer.decodeToString(0, bytesPos)) started = true bytesBuffer = null bytesPos = 0 } if (plusToSpace && ch == '+') { if (!started) { out.append(source, 0, i) started = true } out.append(" ") } else if (started) { out.append(ch) } i++ } } if (bytesBuffer != null) { out.append(bytesBuffer.decodeToString(0, bytesPos)) } return if (!started) source else out.toString() } /** * Transforms a provided [String] object into a new string, containing only valid URL * characters in the UTF-8 encoding. * * - Letters, numbers, unreserved (`_-!.'()*`) and allowed characters are left intact. */ fun encode(source: String, allow: String = "", spaceToPlus: Boolean = false): String { if (source.isEmpty()) { return source } var out: StringBuilder? = null var i = 0 while (i < source.length) { val ch = source[i] if (ch.isUnreserved() || ch in allow) { out?.append(ch) i++ } else { if (out == null) { out = StringBuilder(source.length) out.append(source, 0, i) } val cp = source.codePointAt(i) when { cp < 0x80 -> { if (spaceToPlus && ch == ' ') { out.append('+') } else { out.appendEncodedByte(cp) } i++ } Character.isBmpCodePoint(cp) -> { for (b in ch.toString().encodeToByteArray()) { out.appendEncodedByte(b.toInt()) } i++ } Character.isSupplementaryCodePoint(cp) -> { val high = Character.highSurrogateOf(cp) val low = Character.lowSurrogateOf(cp) for (b in charArrayOf(high, low).concatToString().encodeToByteArray()) { out.appendEncodedByte(b.toInt()) } i += 2 } } } } return out?.toString() ?: source } /** * Returns the Unicode code point at the specified index. * * The `index` parameter is the regular `CharSequence` index, i.e. the number of `Char`s from the start of the character * sequence. * * If the code point at the specified index is part of the Basic Multilingual Plane (BMP), its value can be represented * using a single `Char` and this method will behave exactly like [CharSequence.get]. * Code points outside the BMP are encoded using a surrogate pair – a `Char` containing a value in the high surrogate * range followed by a `Char` containing a value in the low surrogate range. Together these two `Char`s encode a single * code point in one of the supplementary planes. This method will do the necessary decoding and return the value of * that single code point. * * In situations where surrogate characters are encountered that don't form a valid surrogate pair starting at `index`, * this method will return the surrogate code point itself, behaving like [CharSequence.get]. * * If the `index` is out of bounds of this character sequence, this method throws an [IndexOutOfBoundsException]. * * ```kotlin * // Text containing code points outside the BMP (encoded as a surrogate pairs) * val text = "\uD83E\uDD95\uD83E\uDD96" * * var index = 0 * while (index < text.length) { * val codePoint = text.codePointAt(index) * // (Do something with codePoint...) * index += CodePoints.charCount(codePoint) * } * ``` */ private fun CharSequence.codePointAt(index: Int): Int { if (index !in indices) throw IndexOutOfBoundsException("index $index was not in range $indices") val firstChar = this[index] if (firstChar.isHighSurrogate()) { val nextChar = getOrNull(index + 1) if (nextChar?.isLowSurrogate() == true) { return Character.toCodePoint(firstChar, nextChar) } } return firstChar.code } /** * Kotlin Multiplatform equivalent for `java.lang.Character` * * @author aSemy */ private object Character { /** * See https://www.tutorialspoint.com/java/lang/character_issupplementarycodepoint.htm * * Determines whether the specified character (Unicode code point) is in the supplementary character range. * The supplementary character range in the Unicode system falls in `U+10000` to `U+10FFFF`. * * The Unicode code points are divided into two categories: * Basic Multilingual Plane (BMP) code points and Supplementary code points. * BMP code points are present in the range U+0000 to U+FFFF. * * Whereas, supplementary characters are rare characters that are not represented using the original 16-bit Unicode. * For example, these type of characters are used in Chinese or Japanese scripts and hence, are required by the * applications used in these countries. * * @returns `true` if the specified code point falls in the range of supplementary code points * ([MIN_SUPPLEMENTARY_CODE_POINT] to [MAX_CODE_POINT], inclusive), `false` otherwise. */ internal fun isSupplementaryCodePoint(codePoint: Int): Boolean = codePoint in MIN_SUPPLEMENTARY_CODE_POINT..MAX_CODE_POINT internal fun toCodePoint(highSurrogate: Char, lowSurrogate: Char): Int = (highSurrogate.code shl 10) + lowSurrogate.code + SURROGATE_DECODE_OFFSET /** Basic Multilingual Plane (BMP) */ internal fun isBmpCodePoint(codePoint: Int): Boolean = codePoint ushr 16 == 0 internal fun highSurrogateOf(codePoint: Int): Char = ((codePoint ushr 10) + HIGH_SURROGATE_ENCODE_OFFSET.code).toChar() internal fun lowSurrogateOf(codePoint: Int): Char = ((codePoint and 0x3FF) + MIN_LOW_SURROGATE.code).toChar() // private const val MIN_CODE_POINT: Int = 0x000000 private const val MAX_CODE_POINT: Int = 0x10FFFF private const val MIN_SUPPLEMENTARY_CODE_POINT: Int = 0x10000 private const val SURROGATE_DECODE_OFFSET: Int = MIN_SUPPLEMENTARY_CODE_POINT - (MIN_HIGH_SURROGATE.code shl 10) - MIN_LOW_SURROGATE.code private const val HIGH_SURROGATE_ENCODE_OFFSET: Char = MIN_HIGH_SURROGATE - (MIN_SUPPLEMENTARY_CODE_POINT ushr 10) } } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/VideoUtils.kt ================================================ package com.zhangke.framework.utils expect class VideoUtils() { fun getVideoAspect(uri: String): AspectRatio? } ================================================ FILE: framework/src/commonMain/kotlin/com/zhangke/framework/utils/WebFinger.kt ================================================ package com.zhangke.framework.utils import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.network.HttpScheme import kotlinx.serialization.Serializable /** * Supported: * - jw@jakewharton.com * - @jw@jakewharton.com * - acct:@jw@jakewharton.com * - https://m.cmx.im/@jw@jakewharton.com * - https://m.cmx.im/@AtomZ * - m.cmx.im/@jw@jakewharton.com * - jakewharton.com/@jw * * For Bluesky Workaround: @bsky@did * name is bsky * host is did */ @Parcelize @Serializable class WebFinger private constructor( val name: String, val host: String, ) : PlatformParcelable, PlatformSerializable { val did: String? = if (name == NAME_DID) host else null override fun toString(): String { return "@$name@$host" } override fun hashCode(): Int { var result = name.hashCode() result = 31 * result + host.hashCode() return result } override fun equals(other: Any?): Boolean { if (other === this) return true if (other == null) return false if (other !is WebFinger) return false return (other.name == name) && (other.host == host) } fun equalsDomain(other: WebFinger): Boolean { if (this == other) return true if (this.name != other.name) return false if (this.host.endsWith(other.host)) return true if (other.host.endsWith(this.host)) return true return false } companion object { private const val NAME_DID = "did" fun create(content: String, baseUrl: FormalBaseUrl? = null): WebFinger? { if (content.isBlank()) return null return createAsAcct(content, baseUrl) ?: createAsUrl(content) } fun build(name: String, host: String): WebFinger { return WebFinger(name, host) } fun createFromDid(did: String): WebFinger { return WebFinger(NAME_DID, did) } private fun createAsAcct(content: String, baseUrl: FormalBaseUrl? = null): WebFinger? { val fixedAcct = content.removePrefix("acct:").removePrefix("@") val name: String val host: String val split = fixedAcct.split('@') if (split.size == 1 && baseUrl != null) { name = split[0] host = baseUrl.host } else if (split.size == 2) { name = split[0] host = split[1] } else { return null } if (!hostValidate(host)) return null return WebFinger(name, host) } private fun createAsUrl(content: String): WebFinger? { val maybeUrl = content .removePrefix(HttpScheme.HTTP) .removePrefix(HttpScheme.HTTPS) val split = maybeUrl.split('/') if (split.size < 2) return null val urlHost = split[0] if (!hostValidate(urlHost)) return null val maybeAcct = split.last().removePrefix("@") return if (maybeAcct.contains('@')) { createAsAcct(maybeAcct) } else { createAsAcct("$maybeAcct@$urlHost") } } private fun hostValidate(host: String): Boolean { return DomainValidator.validate(host) } } } ================================================ FILE: framework/src/commonMain/res/values/colors.xml ================================================ #FFBB86FC #FF6200EE #FF3700B3 #FF03DAC5 #FF018786 #FF000000 #FFFFFFFF ================================================ FILE: framework/src/commonMain/res/values/ids.xml ================================================ ================================================ FILE: framework/src/commonTest/kotlin/com/zhangke/framework/network/SimpleUriTest.kt ================================================ package com.zhangke.framework.network import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull class SimpleUriTest { @Test fun `should return null when empty`() { assertNull(SimpleUri.parse("")) } @Test fun `should return host when given host`() { val host = "example.com" val uri = SimpleUri.parse("https://$host") assertEquals(host, uri!!.host) } @Test fun `should return scheme when given scheme`() { val scheme = "ftp" val uri = SimpleUri.parse("${scheme}://example.com") assertEquals(scheme, uri!!.scheme) } @Test fun `should has path when given path`() { val uri = SimpleUri.parse("http://example.com/a/b/c?name=we") assertEquals("/a/b/c", uri!!.path) } @Test fun `should return query when given path is empty`() { val uri = SimpleUri.parse("https://a.b/?a=1&b=2") assertEquals("1", uri!!.queries["a"]) assertEquals("2", uri.queries["b"]) } @Test fun `should return query when given query`() { val uri = SimpleUri.parse("https://a.b/path?a=1&b=2") assertEquals("1", uri!!.queries["a"]) assertEquals("2", uri.queries["b"]) } @Test fun testParse() { val simpleUri = SimpleUri.parse("fread://activitypub/platform/detail?baseUrl=https://mastodon.social") assertNotNull(simpleUri) assertEquals("fread", simpleUri.scheme) assertEquals("activitypub", simpleUri.host) assertEquals("/platform/detail", simpleUri.path) assertEquals("https://mastodon.social", simpleUri.queries["baseUrl"]) } } ================================================ FILE: framework/src/commonTest/kotlin/com/zhangke/framework/opml/OpmlParserTest.kt ================================================ package com.zhangke.framework.opml import kotlin.test.Test import kotlin.test.assertEquals class OpmlParserTest { @Test fun testParse() { val xml = """ 中文独立博客列表 """.trimIndent() val result = OpmlParser.parse(xml) assertEquals(13, result.size) assertEquals("透明创业实验", result[0].title) assertEquals("全栈应用开发:精益实践", result[11].title) assertEquals("前端之巅", result[11].children[0].title) assertEquals("Reorx’s Forge", result[12].title) } } ================================================ FILE: framework/src/commonTest/kotlin/com/zhangke/framework/security/Md5Test.kt ================================================ package com.zhangke.framework.security import kotlin.test.Test import kotlin.test.assertEquals class Md5Test { @Test fun testMd5() { val md5 = Md5.md5("123456") assertEquals("e10adc3949ba59abbe56e057f20f883e", md5) } } ================================================ FILE: framework/src/commonTest/kotlin/com/zhangke/framework/utils/ExtractUrlFromTextUtilsTest.kt ================================================ package com.zhangke.framework.utils import kotlin.test.Test import kotlin.test.assertContentEquals class ExtractUrlFromTextUtilsTest { @Test fun `should extract urls from text`() { assertContentEquals( expected = listOf( "https://example.com/path?a=1", "example.org/hello", ), actual = ExtractUrlFromTextUtils.extract( "Visit https://example.com/path?a=1 and example.org/hello now." ), ) } @Test fun `should trim trailing punctuation`() { assertContentEquals( expected = listOf( "https://example.com/path(test)", "https://another.example.com/demo", "https://third.example.com", ), actual = ExtractUrlFromTextUtils.extract( "(https://example.com/path(test)), https://another.example.com/demo. [https://third.example.com]" ), ) } @Test fun `should ignore invalid urls`() { assertContentEquals( expected = emptyList(), actual = ExtractUrlFromTextUtils.extract( "localhost http://-example.com example invalid_domain" ), ) } } ================================================ FILE: framework/src/commonTest/kotlin/com/zhangke/framework/utils/IntExtTest.kt ================================================ package com.zhangke.framework.utils import kotlin.test.Test import kotlin.test.assertEquals class IntExtTest { @Test fun testIntFormatAsCount() { assertEquals("1M", 1_000_000.formatToHumanReadable()) assertEquals("1.1M", 1_100_000.formatToHumanReadable()) assertEquals("1.9M", 1_900_000.formatToHumanReadable()) assertEquals("1.5M", 1_500_000.formatToHumanReadable()) assertEquals("1M", 1_009_000.formatToHumanReadable()) assertEquals("1.1M", 1_090_000.formatToHumanReadable()) assertEquals("10M", 10_000_000.formatToHumanReadable()) assertEquals("1K", 1000.formatToHumanReadable()) assertEquals("10K", 10000.formatToHumanReadable()) assertEquals("89K", 89000.formatToHumanReadable()) assertEquals("99K", 99000.formatToHumanReadable()) assertEquals("999", 999.formatToHumanReadable()) } } ================================================ FILE: framework/src/commonTest/kotlin/com/zhangke/framework/utils/WebFingerTest.kt ================================================ package com.zhangke.framework.utils import kotlin.test.Test class WebFingerTest { @Test fun testCrashedCase() { } } ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/architect/http/HttpClientEngine.ios.kt ================================================ package com.zhangke.framework.architect.http import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.darwin.Darwin actual fun createHttpClientEngine(): HttpClientEngine { return Darwin.create() } ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/architect/theme/FreadTheme.ios.kt ================================================ package com.zhangke.framework.architect.theme import androidx.compose.runtime.Composable @Composable internal actual fun FreadPlatformTheme(darkTheme: Boolean) { } ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/blurhash/BitmapExt.ios.kt ================================================ package com.zhangke.framework.blurhash import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asComposeImageBitmap import org.jetbrains.skia.Bitmap import org.jetbrains.skia.ColorAlphaType import org.jetbrains.skia.ImageInfo actual fun bitmapFromBuffer(buffer: IntArray, width: Int, height: Int): ImageBitmap { val byteArray = getBytes(width, height, buffer) return Bitmap().apply { allocPixels(ImageInfo.makeS32(width, height, ColorAlphaType.PREMUL)) installPixels(imageInfo, byteArray, width * 4) }.asComposeImageBitmap() } private fun getBytes(width: Int, height: Int, buffer: IntArray): ByteArray { val pixels = ByteArray(width * height * 4) var index = 0 for (y in 0 until height) { for (x in 0 until width) { val pixel = buffer[y * width + x] pixels[index++] = ((pixel and 0xFF)).toByte() // Blue component pixels[index++] = (((pixel shr 8) and 0xFF)).toByte() // Green component pixels[index++] = (((pixel shr 16) and 0xFF)).toByte() // Red component pixels[index++] = (((pixel shr 24) and 0xFF)).toByte() // Alpha component } } return pixels } ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/composable/TextString.ios.kt ================================================ package com.zhangke.framework.composable import androidx.compose.runtime.Composable @Composable actual fun stringResource(resId: Int, vararg formatArgs: Any): String { error("stringResource(resId, formatArgs) Not supported on iOS") } actual suspend fun TextString.getString(): String { return when (this) { is TextString.StringText -> string is TextString.ComposeResourceText -> org.jetbrains.compose.resources.getString(res) is TextString.ResourceText -> error("ResourceText Not supported on iOS") } } ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/composable/pick/PickVisualMediaLauncherContainer.ios.kt ================================================ package com.zhangke.framework.composable.pick import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.zhangke.framework.utils.PlatformUri @Composable actual fun PickVisualMediaLauncherContainer( onResult: (List) -> Unit, maxItems: Int, content: @Composable PickVisualMediaLauncherContainerScope.() -> Unit, ) { val scope = remember { PickVisualMediaLauncherContainerScope() } with(scope) { content() } } actual class PickVisualMediaLauncherContainerScope { actual fun launchImage() { TODO("Not yet implemented") } actual fun launchMedia() { TODO("Not yet implemented") } actual fun launchVideo(){ TODO("Not yet implemented") } actual fun launchImageFile() { TODO("Not yet implemented") } actual fun launchVideoFile() { TODO("Not yet implemented") } } ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/date/InstantFormater.ios.kt ================================================ @file:OptIn(ExperimentalTime::class) package com.zhangke.framework.date import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.format import kotlinx.datetime.format.char import kotlinx.datetime.toLocalDateTime import kotlin.time.ExperimentalTime actual class InstantFormater { actual fun formatToMediumDate(instant: Instant): String { return instant.toLocalDateTime(TimeZone.currentSystemDefault()) .format( LocalDateTime.Format { year() char('-') monthNumber() char('-') dayOfMonth() char(' ') hour() char(':') minute() char(':') second() } ) } actual fun formatToMediumDateWithoutTime(instant: Instant): String { return instant.toLocalDateTime(TimeZone.currentSystemDefault()) .format( LocalDateTime.Format { year() char('-') monthNumber() char('-') dayOfMonth() } ) } } ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/datetime/Instant.ios.kt ================================================ package com.zhangke.framework.datetime import com.zhangke.framework.serialize.TimestampAsInstantSerializer import com.zhangke.framework.utils.Parcelize import kotlinx.serialization.Serializable //@Parcelize //@Serializable(with = TimestampAsInstantSerializer::class) //actual class Instant actual (actual val instant: kotlinx.datetime.Instant) ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/permission/RequireLocalStoragePermission.ios.kt ================================================ package com.zhangke.framework.permission import androidx.compose.runtime.Composable @Composable actual fun RequireLocalStoragePermission( onPermissionGranted: suspend () -> Unit, onPermissionDenied: suspend () -> Unit ) { TODO("Not yet implemented") } ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/toast/Toast.ios.kt ================================================ package com.zhangke.framework.toast actual fun toast(message: String?) { } ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/utils/IDNUtils.ios.kt ================================================ package com.zhangke.framework.utils actual class IDNUtils { actual fun toASCII(input: String): String { throw UnsupportedOperationException("IDNUtils is not supported on this platform.") } } ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/utils/ImageCompressUtils.kt ================================================ package com.zhangke.framework.utils actual class ImageCompressUtils { actual fun compress(bytes: ByteArray, targetSize: StorageSize): CompressResult { return CompressResult(bytes, null) } } ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/utils/LanguageUtils.ios.kt ================================================ package com.zhangke.framework.utils import platform.Foundation.NSLocale import platform.Foundation.NSLocaleLanguageCode import platform.Foundation.availableLocaleIdentifiers import platform.Foundation.languageIdentifier import platform.Foundation.systemLocale actual object LanguageUtils { actual fun getAllLanguages(): List { return NSLocale.availableLocaleIdentifiers().map { NSLocale(it as String) } } } actual typealias Locale = NSLocale actual val Locale.languageCode: String get() = objectForKey(NSLocaleLanguageCode) as? String ?: "" actual val Locale.isO3LanguageCode: String get() = languageIdentifier() actual fun Locale.getDisplayName(displayLocale: Locale): String { return displayNameForKey(NSLocaleLanguageCode, displayLocale).orEmpty() } actual fun initLocale(language: String): Locale { return NSLocale(language) } actual fun getDefaultLocale(): Locale { return NSLocale.systemLocale() } ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/utils/PlatformIgnoredOnParcel.ios.kt ================================================ package com.zhangke.framework.utils actual annotation class PlatformIgnoredOnParcel() ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/utils/PlatformParcelable.ios.kt ================================================ package com.zhangke.framework.utils actual interface PlatformParcelable ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/utils/PlatformTransient.ios.kt ================================================ package com.zhangke.framework.utils @Target(AnnotationTarget.PROPERTY) actual annotation class PlatformTransient ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/utils/PlatformUri.ios.kt ================================================ package com.zhangke.framework.utils ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/utils/Serializable.ios.kt ================================================ package com.zhangke.framework.utils actual interface PlatformSerializable ================================================ FILE: framework/src/iosMain/kotlin/com/zhangke/framework/utils/VideoUtils.ios.kt ================================================ package com.zhangke.framework.utils actual class VideoUtils { actual fun getVideoAspect(uri: String): AspectRatio? { return null } } ================================================ FILE: gradle/libs.versions.toml ================================================ [versions] kotlin = "2.3.20" ksp = "2.3.6" kotlinCoroutine = "1.10.2" kotlinCoroutineTest = "1.10.2" kotlinx-serialization = "1.10.0" kotlinx-datetime = "0.7.1-0.6.x-compat" arrow = "2.1.2" compose-multiplatform = "1.10.3" androidxCore = "1.18.0" androidxFragment = "1.8.9" androidxAppcompat = "1.7.1" androidx-annotation = "1.9.1" androidxActivity = "1.13.0" androidx-preference = "1.2.1" androidx-collection = "1.3.0" androidx-sqlite = "2.6.2" androidx-room = "2.8.4" androidx-datastore = "1.1.7" androidx-browser = "1.4.0" androidx-paging = "3.3.6" androidx-media3 = "1.5.1" nav3Core = "1.0.0" jetbrainsNav3 = "1.0.0-alpha06" lifecycleViewmodelNav3 = "2.10.0-alpha07" compose-runtime-tracing = "1.10.1" jetbrains-lifecycle = "2.10.0" koin = "4.2.0" accompanist = "0.37.3" okhttp3 = "4.12.0" ktor = "3.3.0" ktorfit = "2.2.0" auto_service = "1.0.1" krouter = "1.3.7" multiplatformsettings = "1.3.0" androidGradlePlugin = "8.13.2" mockk = "1.13.8" junit = "4.13.2" androidx-test-ext-junit = "1.1.5" espresso-core = "3.5.1" material = "1.13.0" rssparser = "6.0.12" ktml = "0.0.6" jsoup = "1.15.3" google-service-version = "4.4.2" firebaseKmp = "2.1.0" goolgePlayReview = "2.0.2" goolgePlayReviewKtx = "2.0.1" bluesky = "0.3.3" activity-pub-client = "1.0.0" haze = "1.7.2" compose-media-player = "0.8.7" [bundles] androidx-fragment = ["androidx.fragment", "androidx.fragment.ktx"] androidx-activity = ["androidx.activity", "androidx.activity.ktx", "androidx.activity.compose"] androidx-preference = ["androidx.preference", "androidx.preference.ktx"] androidx-datastore = ["androidx-datastore", "androidx.datastore-preferences"] androidx-collection = ["androidx.collection", "androidx.collection.ktx"] androidx-media3 = ["androidx.media3.exoplayer", "androidx.media3.datasource.okhttp", "androidx.media3.ui", "androidx.media3.session", "androidx.media3.database", "androidx.media3.decoder", "androidx.media3.datasource", "androidx.media3.common", "androidx-media3-hls"] kotlin = ["kotlin.stdlib", "kotlin.coroutine.core", "kotlin.coroutine.android"] googlePlayReview = ["googlePlayReview", "googlePlayReviewKtx"] androidx-nav3 = ["androidx-nav3-runtime", "jetbrains-nav3-ui", "lifecycle-viewmodel-nav3"] [libraries] androidx-core = { group = "androidx.core", name = "core", version.ref = "androidxCore" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" } androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "androidx-annotation" } androidx-fragment = { group = "androidx.fragment", name = "fragment", version.ref = "androidxFragment" } androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidxFragment" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "androidxActivity" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "androidxActivity" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } androidx-preference = { group = "androidx.preference", name = "preference", version.ref = "androidx.preference" } androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "androidx.preference" } androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidx.collection" } androidx-collection-ktx = { group = "androidx.collection", name = "collection-ktx", version.ref = "androidx.collection" } androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "androidx-sqlite" } androidx-room = { group = "androidx.room", name = "room-runtime", version.ref = "androidx-room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidx-room" } androidx-datastore = { group = "androidx.datastore", name = "datastore", version.ref = "androidx-datastore" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidx-datastore" } androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidx.browser" } androidx-constraintlayout-compose-kmp = { module = "tech.annexflow.compose:constraintlayout-compose-multiplatform", version = "0.6.0" } androidx-paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "androidx-paging" } jetbrains-lifecycle-runtime = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime", version.ref = "jetbrains-lifecycle" } jetbrains-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "jetbrains-lifecycle" } jetbrains-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "jetbrains-lifecycle" } androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "androidx.media3" } androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "androidx.media3" } androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "androidx.media3" } androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "androidx.media3" } androidx-media3-database = { group = "androidx.media3", name = "media3-database", version.ref = "androidx.media3" } androidx-media3-decoder = { group = "androidx.media3", name = "media3-decoder", version.ref = "androidx.media3" } androidx-media3-datasource = { group = "androidx.media3", name = "media3-datasource", version.ref = "androidx.media3" } androidx-media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "androidx.media3" } androidx-media3-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "androidx.media3" } lifecycle-viewmodel-nav3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } androidx-nav3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } androidx-nav3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } jetbrains-nav3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "jetbrainsNav3" } accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } firebase-kmp-analytics = { module = "dev.gitlive:firebase-analytics", version.ref = "firebaseKmp" } firebase-kmp-crashlytics = { module = "dev.gitlive:firebase-crashlytics", version.ref = "firebaseKmp" } firebase-kmp-messaging = { module = "dev.gitlive:firebase-messaging", version.ref = "firebaseKmp" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } kotlin-coroutine-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinCoroutine" } kotlin-coroutine-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinCoroutine" } kotlin-coroutine-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinCoroutineTest" } kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinx-serialization" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinx-datetime" } okhttp3 = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp3" } okhttp3-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp3" } ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" } ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } ktorfit-lib = { module = "de.jensklingenberg.ktorfit:ktorfit-lib", version.ref = "ktorfit" } ktorfit-converters-response = { module = "de.jensklingenberg.ktorfit:ktorfit-converters-response", version.ref = "ktorfit" } auto-service-annotations = { group = "com.google.auto.service", name = "auto-service-annotations", version.ref = "auto.service" } auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", version = "1.2.0" } arrow-core = { group = "io.arrow-kt", name = "arrow-core", version.ref = "arrow" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } activity-pub-client = { group = "io.github.0xzhangke", name = "activity-pub-client", version.ref = "activity-pub-client" } # Dependencies of the included build-logic android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } #firebase-crashlytics-gradlePlugin = { group = "com.google.firebase", name = "firebase-crashlytics-gradle", version.ref = "firebaseCrashlyticsPlugin" } #firebase-performance-gradlePlugin = { group = "com.google.firebase", name = "perf-plugin", version.ref = "firebasePerfPlugin" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } compose-gradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-multiplatform" } composeCompiler-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } compose-jb-backhandler = { group = "org.jetbrains.compose.ui", name = "ui-backhandler", version.ref = "compose-multiplatform" } krouter-reducing-compiler = { group = "io.github.0xzhangke", name = "krouter-reducing-compiler", version.ref = "krouter" } krouter-collecting-compiler = { group = "io.github.0xzhangke", name = "krouter-collecting-compiler", version.ref = "krouter" } krouter-runtime = { group = "io.github.0xzhangke", name = "krouter-runtime", version.ref = "krouter" } krouter-annotation = { group = "io.github.0xzhangke", name = "krouter-annotation", version.ref = "krouter" } multiplatformsettings-core = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformsettings" } multiplatformsettings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatformsettings" } multiplatformsettings-datastore = { module = "com.russhwolf:multiplatform-settings-datastore", version.ref = "multiplatformsettings" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } rssparser = { group = "com.prof18.rssparser", name = "rssparser", version.ref = "rssparser" } ktml = { group = "moe.tlaster", name = "ktml", version.ref = "ktml" } imageLoader = { group = "io.github.qdsfdhvh", name = "image-loader", version = "1.10.0" } okio = { group = "com.squareup.okio", name = "okio", version = "3.9.0" } ksoup = { module = "com.fleeksoft.ksoup:ksoup-kotlinx", version = "0.2.1" } uri-kmp = { module = "com.eygraber:uri-kmp", version = "0.0.18" } bignum = { module = "com.ionspin.kotlin:bignum", version = "0.3.10" } kermit = { module = "co.touchlab:kermit", version = "2.0.4" } placeholder-material3 = { module = "com.eygraber:compose-placeholder-material3", version = "1.0.8" } leftright = { module = "io.github.charlietap:leftright", version = "0.2.4" } googlePlayReview = { group = "com.google.android.play", name = "review", version.ref = "goolgePlayReview" } googlePlayReviewKtx = { group = "com.google.android.play", name = "review-ktx", version.ref = "goolgePlayReview" } composeRuntimeTracing = { group = "androidx.compose.runtime", name = "runtime-tracing", version.ref = "compose-runtime-tracing" } bluesky = { module = "sh.christian.ozone:bluesky", version.ref = "bluesky" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-core-coroutines = { module = "io.insert-koin:koin-core-coroutines", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } koin-compose-nav3 = { module = "io.insert-koin:koin-compose-navigation3", version = "4.2.0-alpha3" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } haze = { group = "dev.chrisbanes.haze", name = "haze", version.ref = "haze" } haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", version.ref = "haze" } compose-media-player = { group = "io.github.kdroidfilter", name = "composemediaplayer", version.ref = "compose-media-player" } [plugins] ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } jetbrains-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } google-service = { id = "com.google.gms.google-services", version.ref = "google.service.version" } firebase-crashlytics = { id = "com.google.firebase.crashlytics", version = "3.0.2" } ktorfit = { id = "de.jensklingenberg.ktorfit", version.ref = "ktorfit" } room = { id = "androidx.room", version.ref = "androidx-room" } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ #Gradle org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=2g -Dfile.encoding=UTF-8 org.gradle.parallel=true org.gradle.caching=true #org.gradle.configureondemand=true #Android android.useAndroidX=true android.nonTransitiveRClass=true android.enableR8.fullMode=false # Kotlin kotlin.code.style=official # Ksp ksp.incremental=true ksp.incremental.log=true #systemProp.http.proxyHost= #systemProp.http.proxyPort=80 #systemProp.https.proxyHost= #systemProp.https.proxyPort=80 ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s ' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java if ! command -v java >/dev/null 2>&1 then die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @rem SPDX-License-Identifier: Apache-2.0 @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! set EXIT_CODE=%ERRORLEVEL% if %EXIT_CODE% equ 0 set EXIT_CODE=1 if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: iosApp/.gitignore ================================================ # Xcode .DS_Store build/ DerivedData/ *.pbxuser !default.pbxuser *.mode1v3 !default.mode1v3 *.mode2v3 !default.mode2v3 *.perspectivev3 !default.perspectivev3 xcuserdata/ *.xccheckout *.moved-aside *.xcuserstate *.xcscmblueprint # Swift Package Manager .build/ .swiftpm/xcode/package.xcworkspace/ .swiftpm/xcode/package.xcworkspace/xcuserdata # CocoaPods Pods/ Podfile.lock Podfile.swp podfile.swp podfile.lock # Carthage Carthage/Build/ # Xcode Patch *.xcodeproj/* !*.xcodeproj/project.pbxproj !*.xcodeproj/xcshareddata/xcschemes/*.xcscheme !*.xcworkspace/contents.xcworkspacedata !*.xcworkspace/xcshareddata/xcschemes/*.xcscheme # SPM Packages/ Package.pins Package.resolved .swiftpm/ ================================================ FILE: iosApp/Configuration/Config.xcconfig ================================================ TEAM_ID= BUNDLE_ID=com.zhangke.fread.Fread APP_NAME=Fread ================================================ FILE: iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "app-icon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: iosApp/iosApp/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: iosApp/iosApp/ContentView.swift ================================================ import UIKit import FreadKit import SwiftUI struct ContentView: View { private let component: IosActivityComponent init(component: IosActivityComponent) { self.component = component } var body: some View { ComposeView(component: self.component) .ignoresSafeArea(.keyboard) // Compose has own keyboard handler } } struct ComposeView: UIViewControllerRepresentable { private let component: IosActivityComponent init(component: IosActivityComponent) { self.component = component } func makeUIViewController(context: Context) -> UIViewController { component.uiViewControllerFactory() } func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} } ================================================ FILE: iosApp/iosApp/GoogleService-Info.plist ================================================ API_KEY AIzaSyABobDMExO3CYk-jVOPlz-dJ7z2GidFVgo GCM_SENDER_ID 1083712488538 PLIST_VERSION 1 BUNDLE_ID com.zhangke.fread PROJECT_ID fread-515c4 STORAGE_BUCKET fread-515c4.firebasestorage.app IS_ADS_ENABLED IS_ANALYTICS_ENABLED IS_APPINVITE_ENABLED IS_GCM_ENABLED IS_SIGNIN_ENABLED GOOGLE_APP_ID 1:1083712488538:ios:1e6d310f7f93a2ab9d4463 ================================================ FILE: iosApp/iosApp/Info.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString 1.0 CFBundleVersion 1 LSRequiresIPhoneOS CADisableMinimumFrameDurationOnPhone UIApplicationSceneManifest UIApplicationSupportsMultipleScenes UILaunchScreen UIRequiredDeviceCapabilities armv7 UISupportedInterfaceOrientations UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight ================================================ FILE: iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: iosApp/iosApp/iOSApp.swift ================================================ import FreadKit import SwiftUI import FirebaseCore class AppDelegate : UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { // Use Firebase library to configure APIs FirebaseApp.configure() applicationComponent.startupManager.initialize() return true } } @main struct iOSApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate var body: some Scene { WindowGroup { let activityComponent = createActivityComponent( applicationComponent: delegate.applicationComponent ) ContentView(component: activityComponent) } } } private func createApplicationComponent( appDelegate: AppDelegate ) -> IosApplicationComponent { return IosApplicationComponent.companion.create( applicationDelegate: appDelegate ) } private func createActivityComponent( applicationComponent: IosApplicationComponent ) -> IosActivityComponent { return IosActivityComponent.companion.create( applicationComponent: applicationComponent ) } ================================================ FILE: iosApp/iosApp.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 56; objects = { /* Begin PBXBuildFile section */ 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; }; 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; }; 0B5CFD0C2D1563D600065731 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0B5CFD0B2D1563D600065731 /* FirebaseAnalytics */; }; 0B5CFD0E2D1563D600065731 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 0B5CFD0D2D1563D600065731 /* FirebaseCore */; }; 0B5CFD102D1563D600065731 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0B5CFD0F2D1563D600065731 /* FirebaseCrashlytics */; }; 0B5CFD122D1563D600065731 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 0B5CFD112D1563D600065731 /* FirebaseMessaging */; }; 0B5CFD142D1564EA00065731 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 0B5CFD132D1564EA00065731 /* GoogleService-Info.plist */; }; 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; }; 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 0B5CFD132D1564EA00065731 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; 7555FF7B242A565900829871 /* Fread.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Fread.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ B92378962B6B1156000C7307 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 0B5CFD122D1563D600065731 /* FirebaseMessaging in Frameworks */, 0B5CFD102D1563D600065731 /* FirebaseCrashlytics in Frameworks */, 0B5CFD0E2D1563D600065731 /* FirebaseCore in Frameworks */, 0B5CFD0C2D1563D600065731 /* FirebaseAnalytics in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 058557D7273AAEEB004C7B11 /* Preview Content */ = { isa = PBXGroup; children = ( 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */, ); path = "Preview Content"; sourceTree = ""; }; 42799AB246E5F90AF97AA0EF /* Frameworks */ = { isa = PBXGroup; children = ( ); name = Frameworks; sourceTree = ""; }; 7555FF72242A565900829871 = { isa = PBXGroup; children = ( AB1DB47929225F7C00F7AF9C /* Configuration */, 7555FF7D242A565900829871 /* iosApp */, 7555FF7C242A565900829871 /* Products */, 42799AB246E5F90AF97AA0EF /* Frameworks */, ); sourceTree = ""; }; 7555FF7C242A565900829871 /* Products */ = { isa = PBXGroup; children = ( 7555FF7B242A565900829871 /* Fread.app */, ); name = Products; sourceTree = ""; }; 7555FF7D242A565900829871 /* iosApp */ = { isa = PBXGroup; children = ( 0B5CFD132D1564EA00065731 /* GoogleService-Info.plist */, 058557BA273AAA24004C7B11 /* Assets.xcassets */, 7555FF82242A565900829871 /* ContentView.swift */, 7555FF8C242A565B00829871 /* Info.plist */, 2152FB032600AC8F00CF470E /* iOSApp.swift */, 058557D7273AAEEB004C7B11 /* Preview Content */, ); path = iosApp; sourceTree = ""; }; AB1DB47929225F7C00F7AF9C /* Configuration */ = { isa = PBXGroup; children = ( AB3632DC29227652001CCB65 /* Config.xcconfig */, ); path = Configuration; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 7555FF7A242A565900829871 /* iosApp */ = { isa = PBXNativeTarget; buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */; buildPhases = ( F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */, 7555FF77242A565900829871 /* Sources */, B92378962B6B1156000C7307 /* Frameworks */, 7555FF79242A565900829871 /* Resources */, 0B5CFD172D15728E00065731 /* Upload dSYM to Firebase Crashlytics */, ); buildRules = ( ); dependencies = ( ); name = iosApp; packageProductDependencies = ( 0B5CFD0B2D1563D600065731 /* FirebaseAnalytics */, 0B5CFD0D2D1563D600065731 /* FirebaseCore */, 0B5CFD0F2D1563D600065731 /* FirebaseCrashlytics */, 0B5CFD112D1563D600065731 /* FirebaseMessaging */, ); productName = iosApp; productReference = 7555FF7B242A565900829871 /* Fread.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 7555FF73242A565900829871 /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1130; LastUpgradeCheck = 1540; ORGANIZATIONNAME = orgName; TargetAttributes = { 7555FF7A242A565900829871 = { CreatedOnToolsVersion = 11.3.1; }; }; }; buildConfigurationList = 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 7555FF72242A565900829871; packageReferences = ( 0B5CFD0A2D1561E900065731 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); productRefGroup = 7555FF7C242A565900829871 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 7555FF7A242A565900829871 /* iosApp */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 7555FF79242A565900829871 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */, 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */, 0B5CFD142D1564EA00065731 /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ 0B5CFD172D15728E00065731 /* Upload dSYM to Firebase Crashlytics */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}", "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}", "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist", "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)", ); name = "Upload dSYM to Firebase Crashlytics"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n"; }; F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( ); name = "Compile Kotlin Framework"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :app-hosting:embedAndSignAppleFrameworkForXcode\n"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 7555FF77242A565900829871 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, 7555FF83242A565900829871 /* ContentView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ 7555FFA3242A565B00829871 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.3; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; 7555FFA4242A565B00829871 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.3; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; name = Release; }; 7555FFA6242A565B00829871 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; DEVELOPMENT_TEAM = "${TEAM_ID}"; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../app-hosting/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", ); INFOPLIST_FILE = iosApp/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.3; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}"; PRODUCT_NAME = "${APP_NAME}"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 7555FFA7242A565B00829871 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; DEVELOPMENT_TEAM = "${TEAM_ID}"; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../app-hosting/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", ); INFOPLIST_FILE = iosApp/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.3; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}"; PRODUCT_NAME = "${APP_NAME}"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */ = { isa = XCConfigurationList; buildConfigurations = ( 7555FFA3242A565B00829871 /* Debug */, 7555FFA4242A565B00829871 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */ = { isa = XCConfigurationList; buildConfigurations = ( 7555FFA6242A565B00829871 /* Debug */, 7555FFA7242A565B00829871 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ 0B5CFD0A2D1561E900065731 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; requirement = { kind = upToNextMajorVersion; minimumVersion = 11.6.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ 0B5CFD0B2D1563D600065731 /* FirebaseAnalytics */ = { isa = XCSwiftPackageProductDependency; package = 0B5CFD0A2D1561E900065731 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseAnalytics; }; 0B5CFD0D2D1563D600065731 /* FirebaseCore */ = { isa = XCSwiftPackageProductDependency; package = 0B5CFD0A2D1561E900065731 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseCore; }; 0B5CFD0F2D1563D600065731 /* FirebaseCrashlytics */ = { isa = XCSwiftPackageProductDependency; package = 0B5CFD0A2D1561E900065731 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseCrashlytics; }; 0B5CFD112D1563D600065731 /* FirebaseMessaging */ = { isa = XCSwiftPackageProductDependency; package = 0B5CFD0A2D1561E900065731 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseMessaging; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 7555FF73242A565900829871 /* Project object */; } ================================================ FILE: kotlin-inject-relative.md ================================================ # Kotlin Inject 依赖注入关系整理 ## 1. 组件层级与入口 - Android 入口组件:`AndroidApplicationComponent`(`app-hosting/src/androidMain/...`),`@Component` + `@ApplicationScope`,由 `HostingApplication` 中 `AndroidApplicationComponent.create(this)` 创建。 - iOS 入口组件:`IosApplicationComponent`(`app-hosting/src/iosMain/...`),`@Component` + `@ApplicationScope`,由 `IosApplicationComponent.create(...)` 创建。 - Activity 级组件: - `AndroidActivityComponent`(`@ActivityScope`),通过 `@Component val applicationComponent: AndroidApplicationComponent` 作为父组件。 - `IosActivityComponent`(`@ActivityScope`),通过 `@Component val applicationComponent: IosApplicationComponent` 作为父组件。 - 额外组件: - `BrowserBridgeDialogActivityComponent`(Android-only,`commonbiz/common`),用于 BrowserBridge Dialog 的 Activity 级依赖。 简图(逻辑关系): ``` AndroidApplicationComponent (@ApplicationScope) └─ HostingApplicationComponent ├─ CommonComponent + CommonPlatformComponent ├─ SharedScreenModelModule + SharedScreenPlatformModule ├─ ExploreComponent ├─ FeedsComponent ├─ NotificationsComponent + NotificationsComponentPlatform ├─ ProfileComponent ├─ ActivityPubComponent + ActivityPubPlatformComponent ├─ RssComponent + RssPlatformComponent └─ BlueskyComponent + BlueskyPlatformComponent AndroidActivityComponent (@ActivityScope) └─ parent: AndroidApplicationComponent IosActivityComponent (@ActivityScope) └─ parent: IosApplicationComponent ``` ## 2. 作用域 - `@ApplicationScope`:应用级单例(`commonbiz/common/src/commonMain/.../Scopes.kt`)。 - `@ActivityScope`:Activity 级对象,通常由 ActivityComponent 承载。 ## 3. 模块级 DI 结构 ### app-hosting(聚合层) - `HostingApplicationComponent`(`app-hosting/src/commonMain/...`) - 聚合所有 Feature/Plugin/Common 模块组件(见上图)。 - 将 `Set` 组装为 `StatusProvider`(统一内容源入口)。 - 注册 `MainViewModel`、`MainDrawerViewModel` 的 ViewModel Map。 - `AndroidApplicationComponent` / `IosApplicationComponent` - 平台基础依赖(`ApplicationContext`/`UIApplication`、`NSUserDefaults`)。 - 提供 `ImageLoader`。 - iOS 额外绑定 `KRouterStartup` 到 `ModuleStartup` 集合。 - `AndroidActivityComponent` / `IosActivityComponent` - 提供 Activity / UIViewController 与 `FreadApp` 容器。 ### commonbiz/common - `CommonComponent`(`commonbiz/common/src/commonMain/...`) - 提供 `StartupManager`、`DayNightHelper`、`FreadConfigManager`、`StatusProvider` 等核心单例。 - 用 `KotlinInjectViewModelProviderFactory` 组装 ViewModel Map(`ViewModelCreator` + `ViewModelFactory`)。 - 绑定 `CommonStartup`、`FreadConfigModuleStartup` 到 `ModuleStartup` 集合。 - 注册全局 ViewModel(如 `UrlRedirectViewModel`、`SelectAccountForPublishViewModel`)。 - `CommonPlatformComponent`(expect/actual) - Android:Room DB、`FlowSettings`、`browserInterceptorSet`、`oauthHandler`,并把 `FeedsRepoModuleStartup`、`LanguageModuleStartup` 加入 `ModuleStartup` 集合。 - iOS:Room + BundledSQLiteDriver,`FlowSettings` 使用 `NSUserDefaults`。 - `CommonActivityComponent` + `CommonActivityPlatformComponent` - 提供 `ActivityDayNightHelper`,并为 `SystemBrowserLauncher` 做平台绑定。 ### commonbiz/sharedscreen - `SharedScreenModelModule` - 注册共享页 ViewModel(`StatusContextViewModel`、`RssBlogDetailViewModel`、`MultiAccountPublishingViewModel`、`SelectAccountOpenStatusViewModel`)。 - 提供 `ModuleScreenVisitor`,依赖 `IFeedsScreenVisitor` + `IProfileScreenVisitor`(来自 Feeds/Profile 模块)。 - `SharedScreenPlatformModule` - 提供 `SelectedAccountPublishingDatabase`(平台路径差异)。 ### feature/feeds - `FeedsComponent` - 注册 Feeds 管理相关 ViewModel(`ContentHome`、`MixedContent`、`Add/Edit/Import` 等)。 - 提供 `IFeedsScreenVisitor`(被 SharedScreenModelModule 使用)。 ### feature/profile - `ProfileComponent` - 注册 `ProfileHomeViewModel`、`SettingScreenModel`、`AboutViewModel`。 - 提供 `IProfileScreenVisitor`(被 SharedScreenModelModule 使用)。 ### feature/explore - `ExploreComponent` - 注册 Explore/搜索相关 ViewModel(Home、Search、SearchAuthor/Status/Hashtag/Platform 等)。 ### feature/notifications - `NotificationsComponent` - 注册通知页 ViewModel(Home/Container)。 - `NotificationsComponentPlatform` - 提供 `NotificationsDatabase`(平台 Room/SQLite 实现差异)。 ### plugins/activitypub-app - `ActivityPubComponent` - 提供 `ActivityPubLoggedAccountRepo`。 - 将 `ActivityPubProvider` 注入 `Set`。 - 将 `ActivityPubUrlInterceptor` 注入 `Set`。 - 绑定 `ActivityPubStartup` 到 `ModuleStartup` 集合。 - 注册 ActivityPub 相关 ViewModel(账号、内容流、列表、过滤器、用户、搜索、趋势等)。 - `ActivityPubPlatformComponent` - 提供 ActivityPub 相关数据库。 - Android:额外提供 Push 数据库、`PushInfoRepo`、`PushNotificationManager`。 - iOS:Room + BundledSQLiteDriver。 ### plugins/rss - `RssComponent` - 将 `RssStatusProvider` 注入 `Set`。 - 注册 `RssSourceViewModel`。 - `RssPlatformComponent` - 提供 `RssDatabases` 与 `RssParser`(Android 使用 OkHttp,iOS 使用 NSURLSession)。 ### plugins/bluesky - `BlueskyComponent` - 将 `BlueskyProvider` 注入 `Set`。 - 将 `BskyUrlInterceptor` 注入 `Set`。 - 绑定 `BskyStartup` 到 `ModuleStartup` 集合。 - 注册 Bluesky 相关 ViewModel(home、feeds、user、publish、search 等)。 - `BlueskyPlatformComponent` - 提供 `BlueskyLoggedAccountDatabase`(平台 Room/SQLite 实现差异)。 ## 4. 跨模块依赖关系汇总 - `HostingApplicationComponent` 统一聚合各模块的 `@Provides`、`@IntoSet`、`@IntoMap` 产物。 - `StatusProvider` 依赖 `Set`:由 ActivityPub / Rss / Bluesky 三个插件模块贡献。 - `BrowserInterceptor` 集合在 Android 平台通过 ActivityPub/Bluesky 注入,用于统一 URL 拦截与跳转。 - `ModuleStartup` 集合由 Common + 平台模块 + ActivityPub + Bluesky + iOS 的 `KRouterStartup` 共同贡献,`StartupManager.initialize()` 统一触发。 - `SharedScreenModelModule` 依赖 Feeds/Profile 模块的 `IFeedsScreenVisitor`、`IProfileScreenVisitor` 来构建 `ModuleScreenVisitor`。 - ViewModel Map 汇总在 `CommonComponent`,由各模块以 `@IntoMap` 方式注册,最终由 `ViewModelProvider.Factory` 统一提供。 ## 5. 组件访问方式(Android) - `HostingApplication` 实现 `ApplicationComponentProvider`,并设置 `commonComponentProvider` 全局入口。 - `Context.component` -> `AndroidApplicationComponent` - `Context.commonComponent` -> `CommonComponent` - `Context.activityPubComponent` -> `ActivityPubComponent` ================================================ FILE: localization/.gitignore ================================================ /build ================================================ FILE: localization/build.gradle.kts ================================================ plugins { id("fread.project.framework.kmp") id("kotlin-parcelize") } android { namespace = "com.zhangke.fread.localization" sourceSets { getByName("main") { res.srcDirs("src/commonMain/res") resources.srcDirs("src/commonMain/resources") } } } kotlin { sourceSets { commonMain { dependencies { implementation(compose.components.resources) implementation(libs.compose.jb.backhandler) implementation(libs.androidx.annotation) implementation(libs.arrow.core) implementation(libs.jetbrains.lifecycle.runtime) implementation(libs.jetbrains.lifecycle.viewmodel) implementation(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) implementation(libs.ktor.client.core) implementation(libs.ktor.client.serialization.kotlinx.json) implementation(libs.ktor.client.content.negotiation) implementation(libs.imageLoader) implementation(libs.okio) implementation(libs.ksoup) implementation(libs.uri.kmp) implementation(libs.bignum) implementation(libs.kermit) implementation(libs.placeholder.material3) implementation(libs.krouter.runtime) } } commonTest { dependencies { implementation(kotlin("test")) } } androidMain { dependencies { implementation(compose.uiTooling) implementation(compose.preview) } } androidUnitTest { dependencies { } } androidInstrumentedTest { dependencies { implementation(libs.junit) implementation(libs.androidx.test.ext.junit) implementation(libs.androidx.test.espresso.core) } } iosMain { dependencies { implementation(libs.ktor.client.darwin) } } } } compose { resources { publicResClass = true packageOfResClass = "com.zhangke.fread.localization" generateResClass = always } } ================================================ FILE: localization/consumer-rules.pro ================================================ ================================================ FILE: localization/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: localization/src/androidMain/kotlin/com/zhangke/fread/localization/LanguageCodeUtils.kt ================================================ package com.zhangke.fread.localization import java.util.Locale val LanguageCode.locale: Locale get() = when (this) { LanguageCode.EN_US -> Locale.ENGLISH LanguageCode.DE_DE -> Locale("de", "DE") LanguageCode.ES_ES -> Locale("es", "ES") LanguageCode.FR_FR -> Locale("fr", "FR") LanguageCode.JA_JP -> Locale("ja", "JP") LanguageCode.PT_PT -> Locale("pt", "PT") LanguageCode.RU_RU -> Locale("ru", "RU") LanguageCode.ZH_CN -> Locale.SIMPLIFIED_CHINESE LanguageCode.ZH_HK -> Locale("zh", "HK") LanguageCode.ZH_TW -> Locale("zh", "TW") } ================================================ FILE: localization/src/commonMain/composeResources/values/strings.xml ================================================ Skip Alert minute hour day week Cancel Yes This space is empty~ Set duration Try Again Oops! Couldn\'t load. Unknown Error Network Error Resource Not Found Mixed Content · Sources  ago  ago day hour min sec Saving… Save successful Save failed Storage permission is denied, please open it in the settings. Loading Previous Page Content Load Failed, Click to Retry Image Video Login Add Search Login successful Login failed Select Logout Settings Donate Feeds Save All done! Bluesky Bluesky is an open network. With one account, you can access both an easy-to-use social network and a shared identity across the entire social internet. Mastodon Mastodon is a decentralized, not-for-sale social media where users control their timeline, with each server being an independent entity. Mixed Content Mixed Feed brings all your content together. Add sources from Mastodon, Bluesky, RSS and more - Fread combines them into one chronological timeline. Mastodon Add Content Added successfully. Current account: Added successfully. Log in now? Oops! This content is already here. All set! Your content is good to go. This username is already taken. Please enter your username Name Bio Please enter your username Please enter bio Are you sure you want to exit editing? Add Success Please input the server to log in to Please select the server to log in to Input Server Host Please Input Server Host Server Host Please log in Fread supports adding multiple accounts. The added accounts can be used to post, like, forward, comment, etc. You can also view the list created by the added accounts. No content available Public Public, but opted-out of discovery feature Followers only Only people mentioned Content Warning Sent successfully Post failed: %1$s Unsaved changes will be lost. Are you sure you want to exit? Please enter content Some accounts couldn't be published. Successfully posted accounts have been removed. Please try again with the remaining ones:%1$s Select an account No other accounts Search in %1$s Search Failed No results found Search Search in %1$s Accounts Status Hashtags Server Explorer Posts Users Hashtags Add Server Confirm Add Search No search results found Import Please select the OPML file to import Import Add Content Content Name Please enter the content name Please add a subscription source No results found Instance name, URL, User homepage URL, RSS Fread supports adding [[Mastodon]]/[[Bluesky]] as well as [[RSS]] feeds, and even [[mixed content]]. Please enter the Mastodon or Bluesky [[NickName]],[[WebFinger\Handle]] user [[homepage URL]], or the [[RSS]] feed URL here. Please add some content first You'll need to add some subscription sources first. Fread supports a variety of content types and content from different platforms, enabling you to build your own information feed. Add Content Select Content Type Content Successfully Added. Would You Like to Log In Now? Please enter a name Please add a subscription source Please select login server Search Mastodon or Bluesky NickName,WebFinger,URL,RSS Please select an account Please input name Name Are you sure you want to delete this feeds? Abnormal status, configuration not found. Are you sure you want to exit? Are you sure you want to delete? Pick a format What would you like to add? Notifications No logged-in account currently All Mentions Profile About Buy me a coffee to support this project. Inline Video Autoplay If enabled, videos in the Feeds will autoplay. Sensitive content is displayed by default If enabled, sensitive content will be displayed by default (Mastodon only). Dark Mode Dark Mode Light Mode Follow System Open Source Open Source Licenses of Dependencies Used in This Project Give us feedback Join the Telegram group or through other methods oin the Telegram group Create GitHub issue Send email Display Language 简体中文 English Follow System Rating us If you like it, please give us a good rating. Content Font Size Adjust the content's font and icon size Small Medium Large Immersive Navigation Bar The bottom navigation bar hides automatically when scrolling on the homepage. Default timeline position Where I left off Latest posts Theme color Default Follow system Version:  Website:  Created by  Contact me at:  Telegram Group:  Privacy Policy:  Check for update Your app is already up to date New version available, click to update. Select how you'd like to donate Session expired Source Attribution Custom Title Subscription Link Home Page Add Date Last Updated Bluesky Sign in Hosting Provider Username or email address Password Confirmation code Edit Content Dive into more feeds By %1$s Liked by %1$s users By By %1$s · Liked by %2$s users Following Posts Replies Medias Likes Blocked Users Muted Users Mute %1$s Unmute Block %1$s Unblock Do you want to block this user? Do you want to mute this user? Edit Profile Image description text Please input image description text New Blog Please input your blog Description text Option %1$s Voting duration Function Single Multiple Please select style Please enter the content for the vote Media not uploaded yet This function requires login to use Login status is invalid, please login again. Content List Mixed Content · Sources Settings Donate Notification Reject Allow To receive interaction messages, please allow notification permissions. AMOLED Mode When enabled, the background will switch to pure black in dark mode to reduce power consumption and improve contrast in dark environments. Appearance Theme, display and Home style settings Behavior Content display and interaction behavior settings Show top switch-content button on Home Show the button in the top-right of Home to switch to the next content Show top refresh button on Home Show the button in the top-right of Home to refresh content Open links with system browser When enabled, Fread will open links with the system browser. Solid bar background Make the top and bottom bars solid instead of blurred. ================================================ FILE: localization/src/commonMain/composeResources/values/strings_mastodon.xml ================================================ Joined on Joined on %1$s Block %1$s Block %1$s Unblock %1$s Edit Note Enter Private Note Do you want to block this user? Do you want to block this domain? Blocked Users Muted Users Mute %1$s Unmute %1$s Mute User? They won\'t know they\'ve been muted. They can still see your posts, but you won\'t see theirs. You won\'t see posts that mention them. They can mention and follow you, but you won\'t see them. Mute Add Instance Please enter server address Please enter the correct server address Home Local Public Trending %1$s posts · %2$s participants · %3$s post today Are you sure you want to unfollow this hashtag? About Config Not Found No users for now No muted users Unmute No blocked users Unblock Bookmarks Favourites Followed Hashtags Filters Active Expired Edit Filter Title Please enter a title Duration Permanent 30 minutes 1 hour 12 hours 1 day 3 days 1 week Custom Expires on %1$s Hidden Keywords Edit Keyword Keyword Are you sure you want to delete this keyword? Muted words %1$s muted word or phrase Mute from Please select sources to hide Home & List Notifications Public Timelines Threads & Replies Profiles Empty Show with content warning Still show posts that match this filter, but behind a content warning Are you sure you want to delete? Are you sure you want to exit? Whole Word Posts Users Hashtags Lists Create List List Name Show replies to No one Members of the list Anyone I follow List members Hide members in Following If someone is on this list, hide them in your Following timeline to avoid seeing their posts twice. Are you sure you want to remove this user? Please enter your username You have unsaved changes. Are you sure you want to exit? Type to search Are you sure you want to delete this list? OAuthor state exception! Home Local Public Language: %1$s Active month: %1$s Posts Replies Media About About Rules Trends Tags %1$s people in the past 2 days Hot Choose a instance Enter instance name or URL ================================================ FILE: localization/src/commonMain/composeResources/values/strings_status_ui.xml ================================================ reposted Repost Quote Like Comment Delete Share Bookmark Unbookmark Reply Follow Unfollow Pin Unpin Edit Matches filter "%1$s" New Content Are you sure you want to delete this content? Media hidden Show more Hide content Vote %1$s vote · Closed %1$s votes · Closed Open In Browser Open the other instance Copy Link Translate Open with another account Mentioned Only Pinned Edited %1$s favorites %1$s boosts Edited on %1$s Translating… Show Original Confirm to delete this content? Edit Content Name Edit Content Name Please enter the name of the content Feeds on the Home Page Long press and drag to reorder Feeds Can Add to the Home Page Mutuals Blocked Following Follow Pending Follow back They are requesting to follow you Do you want to unfollow this user? Do you want to withdraw your follow request? Are you sure you want to unblock this user? Follows you Posts Followers Following Continued thread Update Version %1$s Update Log:\n%2$s Select Account Likes Bookmarks Edit profile Log out Are you sure you want to log out? Search posts Unknown Notification favourited your post reblog your post View previous voting results %1$s votes · Closed followed you edited a post You have received a follow request post a new blog has severed ties with you quoted your post Replied to your post Following Followers Muted Users Blocked Users Favorited Boosted Mute Block Muted Blocked Are you sure you want to mute this user? Are you sure you want to block this user? Post Type or paste what`s on your mind Replying to [[%1$s]] Anybody can interact Interaction limited Post interaction settings Customize who can interact with this post. Quote settings Allow quote posts Reply settings Allow replies from: Everybody Nobody Or combine these options: Mentioned users Users you follow Your followers Users in "%1$s" Add alt text Descriptive alt text Alt text Oops! Your login info has expired. Please log back in to continue. Sign in Please select account Select Language Detail ALT The current quote is unavailable Cancel repost Anyone Followers only Just me Visibility ================================================ FILE: localization/src/commonMain/composeResources/values-de-rDE/strings.xml ================================================ Überspringen Hinweis Minute Stunde Tag Woche Abbrechen Ja Dieser Bereich ist leer~ Dauer festlegen Erneut versuchen Ups! Konnte nicht geladen werden. Unbekannter Fehler Netzwerkfehler Ressource nicht gefunden Gemischte Inhalte · Quellen  vor  vor Tag Stunde Min Sek Speichern… Erfolgreich gespeichert Speichern fehlgeschlagen Speicherberechtigung verweigert. Bitte in den Einstellungen aktivieren. Vorherige Seite wird geladen Laden fehlgeschlagen, zum Wiederholen tippen Bild Video Anmelden Hinzufügen Suchen Anmeldung erfolgreich Anmeldung fehlgeschlagen Auswählen Abmelden Einstellungen Spenden Feeds Speichern Fertig! Bluesky Bluesky ist ein offenes Netzwerk. Mit nur einem Konto nutzt du ein benutzerfreundliches Social Network und behältst eine gemeinsame Identität im gesamten sozialen Internet. Mastodon Mastodon ist ein dezentrales, nicht-kommerzielles soziales Netzwerk. Nutzerinnen und Nutzer kontrollieren ihre Timeline; jeder Server ist eine eigenständige Instanz. Gemischte Inhalte Mixed Feed bündelt alles an einem Ort. Füge Quellen aus Mastodon, Bluesky, RSS u. v. m. hinzu – Fread kombiniert sie zu einer chronologischen Timeline. Mastodon Inhalt hinzufügen Erfolgreich hinzugefügt. Aktuelles Konto: Erfolgreich hinzugefügt. Jetzt anmelden? Ups! Dieser Inhalt ist bereits vorhanden. Alles bereit! Dein Inhalt ist startklar. Dieser Benutzername ist bereits vergeben. Bitte gib deinen Benutzernamen ein Name Bio Bitte gib deinen Benutzernamen ein Bitte gib deine Bio ein Bearbeitung wirklich verlassen? Erfolgreich hinzugefügt Bitte gib den Server zum Anmelden ein Bitte wähle den Anmeldeserver Server-Hostname eingeben Bitte Server-Hostname eingeben Server-Hostname Bitte anmelden Fread unterstützt mehrere Konten. Hinzugefügte Konten kannst du zum Posten, Liken, Reposten und Kommentieren nutzen und die von ihnen erstellten Listen ansehen. Kein Inhalt verfügbar Öffentlich Öffentlich, aber von Entdecken ausgeschlossen Nur Follower Nur erwähnte Personen Inhaltswarnung Erfolgreich gesendet Senden fehlgeschlagen: %1$s Ungespeicherte Änderungen gehen verloren. Wirklich verlassen? Bitte gib einen Inhalt ein Einige Konten konnten nicht veröffentlichen. Erfolgreich veröffentlichte wurden entfernt. Bitte mit den übrigen erneut versuchen: %1$s Konto auswählen Keine weiteren Konten In %1$s suchen Suche fehlgeschlagen Keine Treffer Suchen In %1$s suchen Accounts Beiträge Hashtags Server Entdecken Beiträge Nutzer Hashtags Server hinzufügen Hinzufügen bestätigen Suche Keine Suchergebnisse Importieren Bitte wähle die zu importierende OPML-Datei Importieren Inhalt hinzufügen Name des Inhalts Bitte gib den Namen des Inhalts ein Bitte eine Quelle hinzufügen Keine Ergebnisse gefunden Instanzname, URL, Nutzer-Homepage-URL, RSS Fread unterstützt [[Mastodon]]/[[Bluesky]] sowie [[RSS]]-Feeds – und sogar [[gemischte Inhalte]]. Bitte gib hier den [[NickName]] von Mastodon/Bluesky, den [[WebFinger\Handle]], die [[Homepage-URL]] des Nutzers oder die [[RSS]]-Feed-URL ein. Füge zuerst Inhalte hinzu Lege zunächst ein paar Quellen an. Fread unterstützt verschiedene Inhaltstypen und Plattformen – so baust du dir deinen eigenen Feed. Inhalt hinzufügen Inhaltstyp auswählen Inhalt hinzugefügt. Jetzt anmelden? Bitte einen Namen eingeben Bitte eine Quelle hinzufügen Bitte Anmeldeserver wählen Suchen Mastodon/Bluesky NickName, WebFinger, URL, RSS Bitte ein Konto auswählen Bitte Namen eingeben Name Möchtest du diesen Feed wirklich löschen? Abnormaler Status: Konfiguration nicht gefunden. Möchtest du wirklich beenden? Möchtest du das wirklich löschen? Format auswählen Was möchtest du hinzufügen? Benachrichtigungen Derzeit kein Konto angemeldet Alle Erwähnungen Profil Über Spendiere mir einen Kaffee zur Unterstützung des Projekts. Inline-Video automatisch abspielen Wenn aktiviert, werden Videos im Feed automatisch abgespielt. Sensible Inhalte standardmäßig anzeigen Wenn aktiviert, werden sensible Inhalte standardmäßig angezeigt (nur Mastodon). Dunkelmodus Dunkelmodus Hellmodus Systemeinstellung folgen Open Source Open-Source-Lizenzen der in diesem Projekt verwendeten Abhängigkeiten Feedback geben Der Telegram-Gruppe beitreten oder andere Wege nutzen Der Telegram-Gruppe beitreten GitHub-Issue erstellen E-Mail senden Anzeigesprache Vereinfachtes Chinesisch Englisch Systemeinstellung folgen Bewerte uns Wenn dir die App gefällt, gib uns bitte eine gute Bewertung. Schriftgröße der Inhalte Schrift- und Symbolgröße der Inhalte anpassen Klein Mittel Groß Immersive Navigationsleiste Die untere Navigationsleiste wird auf der Startseite beim Scrollen automatisch ausgeblendet. Standardposition der Timeline Letzter Lesestand Neueste Beiträge Themenfarbe Standard System folgen Version:  Website:  Erstellt von  Kontakt:  Telegram-Gruppe:  Datenschutzrichtlinie:  Nach Updates suchen Deine App ist auf dem neuesten Stand Neue Version verfügbar – zum Aktualisieren tippen. Wähle deine bevorzugte Spendenart Sitzung abgelaufen Quellenangabe Eigener Titel Abo-Link Startseite Hinzugefügt am Zuletzt aktualisiert Bluesky Anmelden Hosting-Anbieter Benutzername oder E-Mail-Adresse Passwort Bestätigungscode Inhalt bearbeiten Weitere Feeds entdecken Von %1$s Gefällt %1$s Nutzerinnen und Nutzern Von Von %1$s · Gefällt %2$s Personen Folge ich Beiträge Antworten Medien Likes Blockierte Nutzer Stummgeschaltete Nutzer %1$s stummschalten Stummschaltung aufheben %1$s blockieren Blockierung aufheben Möchtest du diesen Nutzer blockieren? Möchtest du diesen Nutzer stummschalten? Profil bearbeiten Bildbeschreibung Bitte Bildbeschreibung eingeben Neuer Beitrag Bitte gib deinen Beitrag ein Beschreibungstext Option %1$s Abstimmungsdauer Funktion Einzelauswahl Mehrfachauswahl Bitte Stil wählen Bitte den Inhalt der Abstimmung eingeben Medien noch nicht hochgeladen Für diese Funktion ist eine Anmeldung erforderlich Anmeldestatus ungültig. Bitte erneut anmelden. Inhaltsliste Gemischte Inhalte · Quellen Einstellungen Spenden Benachrichtigungen Ablehnen Zulassen Um Interaktions-Benachrichtigungen zu erhalten, bitte Berechtigung für Benachrichtigungen erteilen. AMOLED-Modus Wenn aktiviert, wechselt der Hintergrund im Dunkelmodus zu reinem Schwarz, um Energie zu sparen und den Kontrast in dunklen Umgebungen zu erhöhen. Aussehen Einstellungen für Design, Anzeige und Startseitenstil Verhalten Einstellungen für Inhaltsanzeige und Interaktionsverhalten Schaltfläche zum Wechseln des Inhalts oben auf der Startseite anzeigen Oben rechts auf der Startseite die Schaltfläche zum Wechseln zum nächsten Inhalt anzeigen Aktualisierungsschaltfläche oben auf der Startseite anzeigen Oben rechts auf der Startseite die Schaltfläche zum Aktualisieren von Inhalten anzeigen Links im Systembrowser öffnen Wenn aktiviert, öffnet Fread Links im Systembrowser. Solider Leistenhintergrund Macht die obere und untere Leiste einfarbig statt weichgezeichnet. ================================================ FILE: localization/src/commonMain/composeResources/values-de-rDE/strings_mastodon.xml ================================================ Beigetreten am Beigetreten am %1$s %1$s blockieren Domain %1$s blockieren Blockierung von %1$s aufheben Notiz bearbeiten Private Notiz eingeben Möchtest du diesen Nutzer blockieren? Möchtest du diese Domain blockieren? Blockierte Nutzer Stummgeschaltete Nutzer %1$s stummschalten Stummschaltung für %1$s aufheben Nutzer stummschalten? Die Person erfährt nicht, dass sie stummgeschaltet wurde. Sie können deine Beiträge weiterhin sehen, aber du siehst ihre nicht. Du siehst keine Beiträge, in denen sie erwähnt werden. Sie können dich erwähnen und dir folgen, aber du siehst sie nicht. Stummschalten Instanz hinzufügen Bitte Serveradresse eingeben Bitte die korrekte Serveradresse eingeben Startseite Lokal Öffentlich Trends %1$s Beiträge · %2$s Teilnehmende · Heute %3$s Beiträge Möchtest du dieses Hashtag wirklich nicht mehr folgen? Über Konfiguration nicht gefunden Derzeit keine Nutzer Keine stummgeschalteten Nutzer Stummschaltung aufheben Keine blockierten Nutzer Blockierung aufheben Lesezeichen Favoriten Gefolgte Hashtags Filter Aktiv Abgelaufen Filter bearbeiten Titel Bitte einen Titel eingeben Dauer Dauerhaft 30 Minuten 1 Stunde 12 Stunden 1 Tag 3 Tage 1 Woche Benutzerdefiniert Läuft ab am %1$s Ausgeblendete Schlüsselwörter Schlüsselwort bearbeiten Schlüsselwort Möchtest du dieses Schlüsselwort wirklich löschen? Ausgeblendete Wörter %1$s ausgeblendetes Wort oder Ausdruck Ausblenden aus Bitte auszublendende Quellen auswählen Startseite & Listen Benachrichtigungen Öffentliche Timelines Threads & Antworten Profile Leer Mit Inhaltswarnung anzeigen Beiträge, die diesem Filter entsprechen, weiterhin anzeigen – jedoch hinter einer Inhaltswarnung. Möchtest du das wirklich löschen? Möchtest du wirklich beenden? Ganzes Wort Beiträge Nutzer Hashtags Listen Liste erstellen Listenname Antworten anzeigen von Niemand Mitglieder der Liste Alle, denen ich folge Listenmitglieder Mitglieder in „Following“ ausblenden Wenn jemand in dieser Liste ist, in deiner Following-Timeline ausblenden, um doppelte Beiträge zu vermeiden. Möchtest du diesen Nutzer wirklich entfernen? Bitte einen Listennamen eingeben Du hast ungespeicherte Änderungen. Wirklich verlassen? Zum Suchen tippen Möchtest du diese Liste wirklich löschen? OAuth-Statusfehler! Startseite Lokal Öffentlich Sprache: %1$s Monatlich aktiv: %1$s Beiträge Antworten Medien Über Über Regeln Trend-Tags %1$s Personen in den letzten 2 Tagen Beliebt Instanz auswählen Instanzname oder URL eingeben ================================================ FILE: localization/src/commonMain/composeResources/values-de-rDE/strings_status_ui.xml ================================================ geboostet Boost Zitieren Gefällt mir Kommentieren Löschen Teilen Lesezeichen Lesezeichen entfernen Antworten Folgen Entfolgen Anheften Loslösen Bearbeiten Entspricht Filter „%1$s“ Neuer Inhalt Möchtest du diesen Inhalt wirklich löschen? Medien ausgeblendet Mehr anzeigen Inhalt ausblenden Abstimmen %1$s Stimme · Geschlossen %1$s Stimmen · Geschlossen Im Browser öffnen Andere Instanz öffnen Link kopieren Übersetzen Mit anderem Konto öffnen Nur Erwähnte Angeheftet Bearbeitet %1$s Favoriten %1$s Boosts Bearbeitet am %1$s Übersetze… Original anzeigen Diesen Inhalt wirklich löschen? Inhaltsnamen bearbeiten Inhaltsnamen bearbeiten Bitte den Namen des Inhalts eingeben Feeds auf der Startseite Zum Sortieren lange drücken und ziehen Feeds, die zur Startseite hinzugefügt werden können Gegenseitig Blockiert Folge ich Folgen Ausstehend Zurückfolgen Diese Person möchte dir folgen Möchtest du dieser Person nicht mehr folgen? Möchtest du deine Folgeanfrage zurückziehen? Möchtest du diesen Nutzer wirklich entblocken? Folgt dir Beiträge Follower Folgt Gesamten Thread anzeigen Aktualisieren Versionshinweise %1$s:\n%2$s Konto auswählen Likes Lesezeichen Profil bearbeiten Abmelden Möchtest du dich wirklich abmelden? Beiträge suchen Unbekannte Benachrichtigung hat deinen Beitrag favorisiert hat deinen Beitrag geboostet Vorherige Abstimmungsergebnisse anzeigen %1$s Stimmen · Geschlossen folgt dir hat einen Beitrag bearbeitet Du hast eine Folgeanfrage erhalten hat einen neuen Beitrag veröffentlicht hat die Verbindung zu dir beendet hat deinen Beitrag zitiert hat auf deinen Beitrag geantwortet Folge ich Follower Stummgeschaltete Nutzer Blockierte Nutzer Favorisiert Geboostet Stummschalten Blockieren Stummgeschaltet Blockiert Möchtest du diesen Nutzer wirklich stummschalten? Möchtest du diesen Nutzer wirklich blockieren? Posten Schreibe, was dir durch den Kopf geht Antwort an [[%1$s]] Jeder kann interagieren Interaktion eingeschränkt Interaktionseinstellungen Bestimme, wer mit diesem Beitrag interagieren kann. Zitat-Einstellungen Zitieren erlauben Antwort-Einstellungen Antworten erlauben von: Alle Niemand Oder kombiniere diese Optionen: Erwähnte Nutzer Nutzer, denen du folgst Deine Follower Nutzer in „%1$s“ Alt-Text hinzufügen Beschreibender Alt-Text Alt-Text Hoppla! Deine Anmeldedaten sind abgelaufen. Bitte erneut anmelden. Anmelden Bitte Konto auswählen Sprache auswählen Details ALT Das aktuelle Zitat ist nicht verfügbar Weiterleitung aufheben Jeder Nur Follower Nur ich Sichtbarkeit ================================================ FILE: localization/src/commonMain/composeResources/values-es-rES/strings.xml ================================================ Saltar Alerta minuto hora día semana Cancelar Este espacio está vacío~ Establecer duración Intentar de nuevo ¡Ups! No se pudo cargar. Error desconocido Error de red Recurso no encontrado Contenido mixto · Fuentes  hace Hace  día hora min seg Guardando… Guardado correctamente Error al guardar Permiso de almacenamiento denegado. Actívalo en los ajustes. Cargando contenido de la página anterior Error al cargar, toca para reintentar Imagen Vídeo Iniciar sesión Añadir Buscar Inicio de sesión correcto Error de inicio de sesión Seleccionar Cerrar sesión Ajustes Donar Feeds Guardar ¡Todo listo! Bluesky Bluesky es una red abierta. Con una sola cuenta accedes a una red social fácil de usar y mantienes una identidad compartida en todo el internet social. Mastodon Mastodon es una red social descentralizada y sin fines de lucro donde las personas controlan su cronología; cada servidor es una entidad independiente. Contenido mixto Mixed Feed reúne todo tu contenido. Añade fuentes de Mastodon, Bluesky, RSS y más: Fread las combina en una única línea temporal cronológica. Mastodon Añadir contenido Añadido correctamente. Cuenta actual: Añadido correctamente. ¿Quieres iniciar sesión ahora? ¡Ups! Este contenido ya existe. ¡Listo! Tu contenido está preparado. Este nombre de usuario ya está en uso. Introduce tu nombre de usuario Nombre Bio Introduce tu nombre de usuario Introduce tu bio ¿Seguro que quieres salir de la edición? Añadido correctamente Introduce el servidor al que iniciar sesión Selecciona el servidor para iniciar sesión Introduce el host del servidor Introduce el host del servidor Host del servidor Inicia sesión Fread permite añadir varias cuentas. Con ellas puedes publicar, dar me gusta, reenviar, comentar, etc. También puedes ver las listas creadas por esas cuentas. No hay contenido disponible Público Público, pero excluido del descubrimiento Solo seguidores Solo personas mencionadas Aviso de contenido Enviado correctamente Error al publicar: %1$s Se perderán los cambios no guardados. ¿Seguro que quieres salir? Introduce contenido Algunas cuentas no pudieron publicar. Las que sí se publicaron se han excluido. Inténtalo de nuevo con las restantes: %1$s Selecciona una cuenta No hay otras cuentas Buscar en %1$s Error de búsqueda No se encontraron resultados Buscar Buscar en %1$s Cuentas Estados Hashtags Servidor Explorar Publicaciones Usuarios Hashtags Añadir servidor Confirmar añadido Buscar No se encontraron resultados Importar Selecciona el archivo OPML a importar Importar Añadir contenido Nombre del contenido Introduce el nombre del contenido Añade una fuente de suscripción No se encontraron resultados Nombre de instancia, URL, URL de inicio del usuario, RSS Fread admite [[Mastodon]]/[[Bluesky]] así como fuentes [[RSS]] e incluso [[contenido mixto]]. Introduce aquí el [[NickName]] de Mastodon o Bluesky, el [[WebFinger\Handle]], la [[URL de inicio]] del usuario o la URL del feed [[RSS]]. Añade contenido primero Primero debes añadir algunas fuentes. Fread admite distintos tipos de contenido y de plataformas para que construyas tu propio feed. Añadir contenido Seleccionar tipo de contenido Contenido añadido. ¿Quieres iniciar sesión ahora? Introduce un nombre Añade una fuente de suscripción Selecciona el servidor de inicio de sesión Buscar NickName de Mastodon o Bluesky, WebFinger, URL, RSS Selecciona una cuenta Introduce el nombre Nombre ¿Seguro que quieres eliminar este feed? Estado anómalo, configuración no encontrada. ¿Seguro que quieres salir? ¿Seguro que quieres eliminarlo? Elige un formato ¿Qué te gustaría añadir? Notificaciones No hay ninguna cuenta iniciada Todas Menciones Perfil Acerca de Invítame a un café para apoyar este proyecto. Reproducción automática en línea Si está activado, los vídeos del feed se reproducen automáticamente. Mostrar contenido sensible por defecto Si está activado, el contenido sensible se mostrará por defecto (solo Mastodon). Modo oscuro Modo oscuro Modo claro Seguir el sistema Código abierto Licencias de código abierto de las dependencias usadas en este proyecto Envíanos tus comentarios Únete al grupo de Telegram u otros medios Únete al grupo de Telegram Crear issue en GitHub Enviar correo Idioma de visualización Chino simplificado Inglés Seguir el sistema Califícanos Si te gusta, danos una buena valoración. Tamaño de fuente del contenido Ajusta el tamaño de la fuente y de los iconos del contenido Pequeño Mediano Grande Barra de navegación inmersiva La barra de navegación inferior se oculta automáticamente al desplazarte por la página principal. Posición predeterminada de la cronología Donde me quedé Publicaciones más recientes Color del tema Predeterminado Seguir el sistema Versión:  Sitio web:  Creado por  Contáctame en:  Grupo de Telegram:  Política de privacidad:  Buscar actualizaciones Tu app ya está actualizada Nueva versión disponible. Pulsa para actualizar. Selecciona cómo te gustaría donar Sesión caducada Atribución de la fuente Título personalizado Enlace de suscripción Página de inicio Fecha de añadido Última actualización Bluesky Iniciar sesión Proveedor de alojamiento Nombre de usuario o correo electrónico Contraseña Código de confirmación Editar contenido Descubre más feeds Por %1$s Le gusta a %1$s usuarios Por Por %1$s · Le gusta a %2$s usuarios Siguiendo Publicaciones Respuestas Medios Me gusta Usuarios bloqueados Usuarios silenciados Silenciar a %1$s Quitar silencio Bloquear a %1$s Desbloquear ¿Quieres bloquear a este usuario? ¿Quieres silenciar a este usuario? Editar perfil Descripción de la imagen Introduce la descripción de la imagen Nueva publicación Escribe tu publicación Texto descriptivo Opción %1$s Duración de la votación Función Selección única Selección múltiple Selecciona el estilo Introduce el contenido de la votación Los medios aún no se han subido Esta función requiere inicio de sesión Estado de inicio de sesión no válido. Vuelve a iniciar sesión. Lista de contenidos Contenido mixto · Fuentes Ajustes Donar Notificaciones Rechazar Permitir Para recibir mensajes de interacción, permite las notificaciones. Modo AMOLED Al activarlo, el fondo cambiará a negro puro en modo oscuro para reducir el consumo de energía y mejorar el contraste en entornos oscuros. Apariencia Ajustes de tema, visualización y estilo de Inicio Comportamiento Ajustes de visualización de contenido y comportamiento de interacción Mostrar el botón para cambiar de contenido en la parte superior de Inicio Mostrar en la esquina superior derecha de Inicio el botón para cambiar al siguiente contenido Mostrar el botón de actualizar en la parte superior de Inicio Mostrar en la esquina superior derecha de Inicio el botón para actualizar el contenido Abrir enlaces con el navegador del sistema Al activarlo, Fread abrirá los enlaces con el navegador del sistema. Fondo sólido para las barras Hace que las barras superior e inferior sean sólidas en lugar de desenfocadas. ================================================ FILE: localization/src/commonMain/composeResources/values-es-rES/strings_mastodon.xml ================================================ Se unió el Se unió el %1$s Bloquear a %1$s Bloquear %1$s Desbloquear %1$s Editar nota Introduce una nota privada ¿Quieres bloquear a este usuario? ¿Quieres bloquear este dominio? Usuarios bloqueados Usuarios silenciados Silenciar a %1$s Quitar silencio a %1$s ¿Silenciar usuario? No sabrán que han sido silenciados. Podrán ver tus publicaciones, pero tú no verás las suyas. No verás publicaciones que los mencionen. Podrán mencionarte y seguirte, pero no los verás. Silenciar Añadir instancia Introduce la dirección del servidor Introduce la dirección correcta del servidor Inicio Local Público Tendencias %1$s publicaciones · %2$s participantes · %3$s publicación hoy ¿Seguro que quieres dejar de seguir este hashtag? Acerca de Configuración no encontrada Sin usuarios por ahora No hay usuarios silenciados Quitar silencio No hay usuarios bloqueados Desbloquear Marcadores Favoritos Hashtags seguidos Filtros Activos Caducados Editar filtro Título Introduce un título Duración Permanente 30 minutos 1 hora 12 horas 1 día 3 días 1 semana Personalizada Expira el %1$s Palabras clave ocultas Editar palabra clave Palabra clave ¿Seguro que quieres eliminar esta palabra clave? Palabras silenciadas %1$s palabra o frase silenciada Silenciar de Selecciona las fuentes a ocultar Inicio y listas Notificaciones Cronologías públicas Hilos y respuestas Perfiles Ninguna Mostrar con advertencia de contenido Seguir mostrando las publicaciones que coincidan con este filtro, pero con una advertencia de contenido. ¿Seguro que quieres eliminar? ¿Seguro que quieres salir? Palabra completa Publicaciones Usuarios Hashtags Listas Crear lista Nombre de la lista Mostrar respuestas a Nadie Miembros de la lista Cualquiera a quien sigo Miembros de la lista Ocultar miembros en “Siguiendo” Si alguien está en esta lista, escóndelo en tu cronología de Siguiendo para evitar ver sus publicaciones dos veces. ¿Seguro que quieres eliminar a este usuario? Introduce tu nombre de usuario Tienes cambios sin guardar. ¿Seguro que quieres salir? Escribe para buscar ¿Seguro que quieres eliminar esta lista? ¡Excepción de estado de OAuth! Inicio Local Público Idioma: %1$s Activos mensuales: %1$s Publicaciones Respuestas Medios Acerca de Acerca de Reglas Etiquetas en tendencia %1$s personas en los últimos 2 días Popular Elige una instancia Introduce el nombre de la instancia o la URL ================================================ FILE: localization/src/commonMain/composeResources/values-es-rES/strings_status_ui.xml ================================================ impulsado Impulsar Citar Me gusta Comentar Eliminar Compartir Guardar Quitar de guardados Responder Seguir Dejar de seguir Fijar Quitar fijado Editar Coincide con el filtro "%1$s" Nuevo contenido ¿Seguro que quieres eliminar este contenido? Contenido oculto Mostrar más Ocultar contenido Votar %1$s voto · Cerrado %1$s votos · Cerrado Abrir en navegador Abrir la otra instancia Copiar enlace Traducir Abrir con otra cuenta Solo mencionados Fijado Editado %1$s me gusta %1$s impulsos Editado el %1$s Traduciendo… Mostrar original ¿Confirmas eliminar este contenido? Editar nombre del contenido Editar nombre del contenido Introduce el nombre del contenido Feeds en la página de inicio Mantén pulsado y arrastra para reordenar Feeds que se pueden añadir a inicio Mutuos Bloqueado Siguiendo Seguir Pendiente Seguir de vuelta Han solicitado seguirte ¿Quieres dejar de seguir a este usuario? ¿Quieres cancelar tu solicitud de seguimiento? ¿Seguro que quieres desbloquear a este usuario? Te sigue Publicaciones Seguidores Siguiendo Ver conversación Actualizar Registro de cambios de la versión %1$s:\n%2$s Seleccionar cuenta Me gusta Guardados Editar perfil Cerrar sesión ¿Seguro que quieres cerrar sesión? Buscar publicaciones Notificación desconocida le ha dado me gusta a tu publicación ha compartido tu publicación Ver resultados de la votación %1$s votos · Cerrado te ha seguido ha editado una publicación Has recibido una solicitud de seguimiento ha publicado un nuevo blog ha roto la conexión contigo citó tu publicación respondió a tu publicación Siguiendo Seguidores Usuarios silenciados Usuarios bloqueados A quienes les gustó Quienes compartieron Silenciar Bloquear Silenciado Bloqueado ¿Seguro que quieres silenciar a este usuario? ¿Seguro que quieres bloquear a este usuario? Publicar Escribe o pega lo que piensas Respondiendo a [[%1$s]] Cualquiera puede interactuar Interacción limitada Ajustes de interacción de la publicación Personaliza quién puede interactuar con esta publicación. Opciones de cita Permitir citar publicaciones Opciones de respuesta Permitir respuestas de: Todos Nadie O combina estas opciones: Usuarios mencionados Usuarios que sigues Tus seguidores Usuarios en "%1$s" Añadir texto alternativo Texto alternativo descriptivo Texto alternativo ¡Ups! Tu sesión ha caducado. Inicia sesión nuevamente para continuar. Iniciar sesión Selecciona una cuenta Seleccionar idioma Detalle ALT La cita actual no está disponible Cancelar reenvío Cualquiera Solo seguidores Solo yo Visibilidad ================================================ FILE: localization/src/commonMain/composeResources/values-fr-rFR/strings.xml ================================================ Passer Alerte minute heure jour semaine Annuler Oui Cet espace est vide~ Définir la durée Réessayer Oups ! Chargement impossible. Erreur inconnue Erreur réseau Ressource introuvable Contenu mixte · Sources  plus tôt  plus tôt jour heure min s Enregistrement… Enregistré avec succès Échec de l’enregistrement L’autorisation de stockage est refusée, veuillez l’activer dans les réglages. Chargement du contenu de la page précédente Échec du chargement, touchez pour réessayer Image Vidéo Se connecter Ajouter Rechercher Connexion réussie Échec de la connexion Sélectionner Se déconnecter Réglages Faire un don Flux Enregistrer C’est fait ! Bluesky Bluesky est un réseau ouvert. Avec un seul compte, vous accédez à un réseau social simple d’usage et à une identité partagée sur tout l’internet social. Mastodon Mastodon est un média social décentralisé et non commercial où les utilisateurs contrôlent leur fil, chaque serveur étant une entité indépendante. Contenu mixte Le flux mixte rassemble tout votre contenu. Ajoutez des sources depuis Mastodon, Bluesky, RSS et plus encore – Fread les combine en une timeline chronologique. Mastodon Ajouter du contenu Ajout réussi. Compte actuel : Ajout réussi. Se connecter maintenant ? Oups ! Ce contenu est déjà présent. Parfait ! Votre contenu est prêt. Ce nom d’utilisateur est déjà pris. Veuillez saisir votre nom d’utilisateur Nom Bio Veuillez saisir votre nom d’utilisateur Veuillez saisir votre bio Voulez-vous vraiment quitter l’édition ? Ajout réussi Veuillez indiquer le serveur sur lequel vous connecter Veuillez choisir le serveur de connexion Saisir l’hôte du serveur Veuillez saisir l’hôte du serveur Hôte du serveur Veuillez vous connecter Fread permet d’ajouter plusieurs comptes. Les comptes ajoutés peuvent publier, aimer, partager, commenter, etc. Vous pouvez aussi voir les listes créées par ces comptes. Aucun contenu disponible Public Public, mais exclu de la découverte Abonnés uniquement Uniquement les personnes mentionnées Alerte de contenu Envoyé avec succès Échec de la publication : %1$s Les modifications non enregistrées seront perdues. Quitter quand même ? Veuillez saisir du contenu Certaines publications de comptes ont échoué. Les comptes publiés avec succès ont été retirés. Réessayez avec les comptes restants : %1$s Sélectionner un compte Aucun autre compte Rechercher dans %1$s Échec de la recherche Aucun résultat Rechercher Rechercher dans %1$s Comptes Statuts Hashtags Serveur Explorer Publications Utilisateurs Hashtags Ajouter un serveur Confirmer l’ajout Recherche Aucun résultat trouvé Importer Veuillez sélectionner le fichier OPML à importer Importer Ajouter du contenu Nom du contenu Veuillez saisir le nom du contenu Veuillez ajouter une source d’abonnement Aucun résultat trouvé Nom d’instance, URL, page d’accueil de l’utilisateur, RSS Fread permet d’ajouter des contenus [[Mastodon]]/[[Bluesky]] ainsi que des flux [[RSS]], voire du [[contenu mixte]]. Veuillez saisir ici le [[NickName]] Mastodon ou Bluesky, le [[WebFinger\Handle]], l’[[URL de la page d’accueil]] de l’utilisateur, ou l’URL du flux [[RSS]]. Veuillez d’abord ajouter du contenu Vous devez d’abord ajouter des sources d’abonnement. Fread prend en charge différents types de contenu et de plateformes pour créer votre propre fil d’informations. Ajouter du contenu Sélectionner le type de contenu Contenu ajouté avec succès. Souhaitez-vous vous connecter maintenant ? Veuillez saisir un nom Veuillez ajouter une source d’abonnement Veuillez sélectionner le serveur de connexion Recherche Pseudo Mastodon ou Bluesky, WebFinger, URL, RSS Veuillez sélectionner un compte Veuillez saisir un nom Nom Voulez-vous vraiment supprimer ce flux ? État anormal, configuration introuvable. Voulez-vous vraiment quitter ? Voulez-vous vraiment supprimer ? Choisir un format Qu’aimeriez-vous ajouter ? Notifications Aucun compte connecté pour le moment Tous Mentions Profil À propos Offrez-moi un café pour soutenir ce projet. Lecture auto des vidéos intégrées Si activé, les vidéos dans les flux se lanceront automatiquement. Afficher par défaut le contenu sensible Si activé, le contenu sensible sera affiché par défaut (Mastodon uniquement). Mode sombre Mode sombre Mode clair Suivre le système Open source Licences open source des dépendances utilisées dans ce projet Donnez-nous votre avis Rejoignez le groupe Telegram ou utilisez d’autres moyens Rejoindre le groupe Telegram Créer une issue GitHub Envoyer un e-mail Langue d’affichage 简体中文 English Suivre le système Nous évaluer Si l’appli vous plaît, laissez-nous une bonne note. Taille de police du contenu Ajuster la taille de la police et des icônes du contenu Petite Moyenne Grande Barre de navigation immersive La barre de navigation inférieure se masque automatiquement lors du défilement sur la page d’accueil. Position par défaut sur le fil Là où je me suis arrêté Dernières publications Couleur du thème Par défaut Suivre le système Version :  Site :  Créé par  Contact :  Groupe Telegram :  Politique de confidentialité :  Rechercher une mise à jour Votre application est à jour Nouvelle version disponible, touchez pour mettre à jour. Choisissez votre mode de don Session expirée Attribution de la source Titre personnalisé Lien d’abonnement Page d’accueil Date d’ajout Dernière mise à jour Bluesky Se connecter Fournisseur d’hébergement Nom d’utilisateur ou e-mail Mot de passe Code de confirmation Modifier le contenu Explorer plus de flux Par %1$s Aimé par %1$s utilisateurs Par Par %1$s · Aimé par %2$s utilisateurs Abonnements Publications Réponses Médias Mentions « J’aime » Utilisateurs bloqués Utilisateurs masqués Masquer %1$s Rétablir Bloquer %1$s Débloquer Voulez-vous bloquer cet utilisateur ? Voulez-vous masquer cet utilisateur ? Modifier le profil Texte de description de l’image Veuillez saisir le texte de description de l’image Nouveau billet Veuillez saisir votre billet Texte descriptif Option %1$s Durée du vote Fonction Unique Multiple Veuillez choisir un style Veuillez saisir le contenu du vote Média non encore téléversé Cette fonctionnalité nécessite une connexion Session invalide, veuillez vous reconnecter. Liste de contenu Contenu mixte · Sources Réglages Faire un don Notifications Refuser Autoriser Pour recevoir les messages d’interaction, veuillez autoriser les notifications. Mode AMOLED Une fois activé, l’arrière-plan devient noir pur en mode sombre afin de réduire la consommation d’énergie et d’améliorer le contraste dans les environnements sombres. Apparence Paramètres du thème, de l’affichage et du style de l’accueil Comportement Paramètres d’affichage du contenu et de comportement d’interaction Afficher le bouton de changement de contenu en haut de l’accueil Afficher en haut à droite de l’accueil le bouton pour passer au contenu suivant Afficher le bouton d’actualisation en haut de l’accueil Afficher en haut à droite de l’accueil le bouton pour actualiser le contenu Ouvrir les liens avec le navigateur du système Lorsqu’elle est activée, Fread ouvrira les liens avec le navigateur du système. Fond solide des barres Rend les barres supérieure et inférieure en fond uni au lieu d’un effet flou. ================================================ FILE: localization/src/commonMain/composeResources/values-fr-rFR/strings_mastodon.xml ================================================ Inscrit le Inscrit le %1$s Bloquer %1$s Bloquer %1$s Débloquer %1$s Modifier la note Saisir une note privée Voulez-vous bloquer cet utilisateur ? Voulez-vous bloquer ce domaine ? Utilisateurs bloqués Utilisateurs masqués Masquer %1$s Rétablir %1$s Masquer cet utilisateur ? Ils ne sauront pas qu’ils ont été masqués. Ils peuvent toujours voir vos publications, mais vous ne verrez pas les leurs. Vous ne verrez pas les publications qui les mentionnent. Ils peuvent vous mentionner et vous suivre, mais vous ne les verrez pas. Masquer Ajouter une instance Veuillez saisir l’adresse du serveur Veuillez saisir l’adresse du serveur correcte Accueil Local Public Tendances %1$s publications · %2$s participants · %3$s publication aujourd’hui Voulez-vous vraiment vous désabonner de ce hashtag ? À propos Configuration introuvable Aucun utilisateur pour le moment Aucun utilisateur masqué Rétablir Aucun utilisateur bloqué Débloquer Signets Favoris Hashtags suivis Filtres Actif Expiré Modifier le filtre Titre Veuillez saisir un titre Durée Permanent 30 minutes 1 heure 12 heures 1 jour 3 jours 1 semaine Personnalisé Expire le %1$s Mots-clés masqués Modifier le mot-clé Mot-clé Voulez-vous vraiment supprimer ce mot-clé ? Mots masqués %1$s mot ou expression masqué(e) Masquer depuis Veuillez sélectionner les sources à masquer Accueil & Liste Notifications Fils publics Discussions & réponses Profils Aucun Afficher avec avertissement de contenu Afficher quand même les publications correspondant à ce filtre, mais derrière un avertissement de contenu Voulez-vous vraiment supprimer ? Voulez-vous vraiment quitter ? Mot entier Publications Utilisateurs Hashtags Listes Créer une liste Nom de la liste Afficher les réponses de Personne Membres de la liste Toute personne que je suis Membres de la liste Masquer les membres dans Abonnements Si quelqu’un figure dans cette liste, masquez-le dans votre fil Abonnements pour éviter les doublons. Voulez-vous vraiment retirer cet utilisateur ? Veuillez saisir votre nom d’utilisateur Des modifications ne sont pas enregistrées. Voulez-vous vraiment quitter ? Tapez pour rechercher Voulez-vous vraiment supprimer cette liste ? Anomalie d’état OAuth ! Accueil Local Public Langue : %1$s Mois actifs : %1$s Publications Réponses Médias À propos À propos Règles Sujets tendance %1$s personnes au cours des 2 derniers jours Populaire Choisir une instance Saisissez le nom de l’instance ou l’URL ================================================ FILE: localization/src/commonMain/composeResources/values-fr-rFR/strings_status_ui.xml ================================================ partagé Partager Citer J’aime Commenter Supprimer Partager Ajouter aux favoris Retirer des favoris Répondre Suivre Ne plus suivre Épingler Désépingler Modifier Correspond au filtre « %1$s » Nouveau contenu Voulez-vous vraiment supprimer ce contenu ? Média masqué Voir plus Masquer le contenu Voter %1$s vote · Clos %1$s votes · Clos Ouvrir dans le navigateur Ouvrir l’autre instance Copier le lien Traduire Ouvrir avec un autre compte Mentions uniquement Épinglé Modifié %1$s favoris %1$s partages Modifié le %1$s Traduction… Afficher l’original Confirmer la suppression de ce contenu ? Modifier le nom du contenu Nom du contenu Veuillez saisir le nom du contenu Flux sur la page d’accueil Appuyez longuement et faites glisser pour réorganiser Flux pouvant être ajoutés à la page d’accueil Abonnements mutuels Bloqué Abonnements Suivre En attente Suivre en retour Cet utilisateur souhaite vous suivre Voulez-vous arrêter de suivre cet utilisateur ? Voulez-vous annuler votre demande de suivi ? Voulez-vous vraiment débloquer cet utilisateur ? Vous suit Publications Abonnés Abonnements Fil continué Mise à jour Journal de mise à jour version %1$s :\n%2$s Sélectionner un compte J’aime Favoris Modifier le profil Se déconnecter Voulez-vous vraiment vous déconnecter ? Rechercher des publications Notification inconnue a ajouté votre publication aux favoris a partagé votre publication Voir les résultats précédents du vote %1$s votes · Clos vous suit a modifié une publication Vous avez reçu une demande de suivi a publié un nouveau billet a rompu les liens avec vous a cité votre publication a répondu à votre publication Abonnements Abonnés Utilisateurs masqués Utilisateurs bloqués Favoris Partages Masquer Bloquer Masqué Bloqué Voulez-vous vraiment masquer cet utilisateur ? Voulez-vous vraiment bloquer cet utilisateur ? Publier Écrivez ou collez ce que vous pensez Réponse à [[%1$s]] Tout le monde peut interagir Interaction limitée Paramètres d’interaction de la publication Personnalisez qui peut interagir avec cette publication. Paramètres de citation Autoriser les citations Paramètres de réponse Autoriser les réponses de : Tout le monde Personne Ou combiner ces options : Utilisateurs mentionnés Utilisateurs que vous suivez Vos abonnés Utilisateurs dans « %1$s » Ajouter un texte alternatif Texte descriptif alternatif Texte alternatif Oups ! Votre session a expiré. Veuillez vous reconnecter pour continuer. Se connecter Veuillez sélectionner un compte Sélectionner la langue Détail ALT La citation actuelle n’est pas disponible Annuler le partage Tout le monde Abonnés uniquement Moi seulement Visibilité ================================================ FILE: localization/src/commonMain/composeResources/values-ja-rJP/strings.xml ================================================ スキップ アラート 時間 キャンセル OK ここには何もありません~ 期間を設定 再試行 おっと!読み込みに失敗しました。 不明なエラー ネットワークエラー リソースが見つかりません ミックスコンテンツ · 件のフィード 時間 保存中… 保存しました 保存に失敗しました ストレージ権限が拒否されました。設定で有効にしてください。 前のページを読み込み中 読み込みに失敗しました。タップして再試行 画像 動画 ログイン 追加 検索 ログインに成功しました ログインに失敗しました 選択 ログアウト 設定 寄付 フィード 保存 完了 Bluesky Bluesky はオープンなネットワークです。1 つのアカウントで使いやすいソーシャルネットワークにアクセスでき、ソーシャルインターネット全体で共有アイデンティティを持てます。 マストドン Mastodon は分散型・非商業的なソーシャルメディアで、ユーザーが自分のタイムラインを管理でき、各サーバーは独立した存在です。 ミックスコンテンツ ミックスコンテンツは Fread が提供するカスタム統合フィードです。マストドン、Bluesky、RSS などのプラットフォームからソースを追加でき、これらのコンテンツを新しい順の一つのフィードにまとめます。 マストドン コンテンツを追加 追加しました。現在のアカウント: 追加しました。ログインしますか? このコンテンツはすでに存在します コンテンツを追加しました この名前は既に使用されています ユーザー名を入力してください 名前 プロフィール ユーザー名を入力 プロフィールを入力 編集を終了しますか? 追加しました ログインするサーバーを入力してください ログイン先のサーバーを選択してください サーバーアドレスを入力してください サーバーアドレスを入力してください サーバーアドレス ログインしてください Fread は複数アカウントの追加に対応しています。追加したアカウントで投稿・いいね・ブースト・コメントなどができ、追加済みアカウントが作成したリストも閲覧できます。 コンテンツはありません 公開 公開(探索に表示しない) フォロワーのみ メンションした相手のみ コンテンツ警告 送信しました 送信に失敗しました:%1$s 編集内容は保存されません。終了しますか? 内容を入力してください 一部のアカウントで投稿に失敗しました。成功したアカウントはキューから削除しました。残りで再試行してください:%1$s アカウントを選択 他のアカウントはありません %1$s 内を検索 検索に失敗しました 結果が見つかりません 検索 %1$s を検索 ユーザー 投稿 ハッシュタグ サーバー みつける 投稿 ユーザー トピック サービスを追加 追加を確定 検索 該当する検索結果はありません インポート インポートする OPML ファイルを選択してください インポート コンテンツを追加 コンテンツ名 コンテンツ名を入力してください 購読元を追加してください 結果が見つかりません インスタンス名・URL・ユーザーアドレス・RSS Fread は [[マストドン]]、[[Bluesky]]、[[RSS]] のコンテンツ、さらには [[ミックスコンテンツ]] の追加に対応しています。 ここにマストドンまたは Bluesky の[[ユーザー名]]、[[WebFinger\Handle]]、ユーザーの[[ホームページURL]]、または [[RSS]] の URL を入力してください。 まずコンテンツを追加してください 先に購読元を追加する必要があります。Fread は多様なコンテンツタイプや異なるプラットフォームのミックスに対応し、あなた専用の情報フィードを作成できます。 コンテンツを追加 コンテンツタイプを選択 追加しました。ログインしますか? 名前を入力してください 情報源を追加してください ログイン先のサーバーを選択してください 検索 マストドン/Bluesky のユーザー名、WebFinger、URL、RSS アカウントを選択してください コンテンツ名を入力してください 名前 このコンテンツを削除しますか? 異常が発生しました。設定が見つかりません。 終了してもよろしいですか? 削除してもよろしいですか? コンテンツタイプを選択 追加するコンテンツタイプを選択してください 通知 現在、ログイン中のアカウントはありません すべて メンション アカウント 概要 このプロジェクトの支援にコーヒーをごちそうください インライン動画の自動再生 有効にすると、フィード内の動画が自動再生されます。 センシティブなコンテンツを常に表示 有効にすると、センシティブなコンテンツを既定で表示します(Mastodonのみ)。 ダークモード ダークモード ライトモード システムに合わせる オープンソース表記 本プロジェクトで使用している依存ライブラリのライセンス表記 フィードバックを送る Telegram グループに参加するか、その他の方法でご連絡ください Telegram グループに参加 GitHub の issue を作成 メールを送信 表示言語 簡体字中国語 英語 システムに合わせる 評価する 気に入っていただけたらストアで高評価をお願いします コンテンツの文字サイズ コンテンツの文字とアイコンのサイズを調整します イマーシブナビゲーションバー 有効にすると、ホームをスクロール中に下部のナビゲーションバーが自動的に隠れます。 ホームのタイムライン既定位置 最後に読んだ位置 最新の投稿 テーマカラー 既定 システム バージョン: 公式サイト: 開発者: 連絡先: Telegram グループ: プライバシーポリシー: アップデートを確認 最新バージョンを利用中です 新しいバージョンがあります。タップして更新。 寄付方法を選択してください ログインが無効です 情報源のプロパティ カスタムタイトル 購読 URL ホーム 追加日 最終更新日 Bluesky ログイン ホスティングプロバイダ ユーザー名またはメールアドレス パスワード 認証コード 編集 ほかのフィードをもっと見る 作成者 %1$s %1$s 人がいいね 作成者 作成者 %1$s · いいね %2$s 件 フォロー中 投稿 返信 メディア いいね ブロックしたユーザー ミュートしたユーザー %1$s をミュート ミュート解除 %1$s をブロック ブロック解除 このユーザーをブロックしますか? このユーザーをミュートしますか? プロフィールを編集 説明文 説明文を入力してください 新規ブログ ブログ本文を入力してください 説明文 選択肢 %1$s 投票期間 機能 単一選択 複数選択 機能を選択してください 投票内容を入力してください メディアがまだアップロードされていません この機能を利用するにはログインが必要です ログイン状態が無効です。再度ログインしてください。 コンテンツ一覧 ミックスコンテンツ · 件のフィード 設定 寄付 通知の許可 拒否 許可 交流に関する通知を受け取るには、通知を許可してください。 AMOLEDモード 有効にすると、ダークモードで背景が純黒になり、消費電力を抑えつつ暗い環境でのコントラストが向上します。 外観 テーマ、表示、ホーム画面スタイルの設定 動作 コンテンツ表示と操作動作の設定 ホーム上部のコンテンツ切り替えボタンを表示 ホーム右上に次のコンテンツへ切り替えるボタンを表示する ホーム上部の更新ボタンを表示 ホーム右上に内容を更新するボタンを表示する システムブラウザでリンクを開く 有効にすると、Fread はリンクをシステムブラウザで開きます。 上下バーを不透明背景にする 上部バーと下部バーをぼかしではなく単色にします。 ================================================ FILE: localization/src/commonMain/composeResources/values-ja-rJP/strings_mastodon.xml ================================================ 参加日時 %1$s に参加 %1$s をブロック %1$s をブロック %1$s のブロックを解除 メモを編集 メモを入力してください 相手をブロックしますか? 相手のインスタンスをブロックしますか? ブロックしたユーザー ミュートしたユーザー %1$s をミュート %1$s のミュートを解除 ユーザーをミュートしますか? 相手にミュートは通知されません。 相手はあなたの投稿を見られますが、あなたは相手の投稿を見ません。 相手が言及された投稿も表示されません。 相手はあなたに言及・フォローできますが、あなた側では表示されません。 ミュート インスタンスを追加 サーバーアドレスを入力してください 正しいサーバーアドレスを入力してください ホーム ローカル パブリック トレンド %1$s 件の投稿 · 参加者 %2$s 人 · 本日 %3$s 件の投稿 この話題のフォローを解除しますか? 概要 設定が見つかりません ユーザーはいません ミュート中のユーザーはいません ミュート解除 ブロック中のユーザーはいません ブロック解除 ブックマーク いいねした内容 フォロー中の話題 フィルター 有効 期限切れ フィルターを編集 タイトル タイトルを入力してください 期間 永久 30 分 1 時間 12 時間 1 日 3 日 1 週間 カスタム %1$s に終了 非表示のキーワード キーワードを編集 キーワード このキーワードを削除しますか? 非表示キーワード 非表示のキーワード %1$s 件 非表示にする場所 非表示にする場所を選択してください ホーム & リスト 通知 公開タイムライン 投稿 & 返信 プロフィール なし コンテンツ警告付きで表示 フィルターに一致する投稿は表示しますが、コンテンツ警告の裏に隠します。 削除してもよろしいですか? 終了してもよろしいですか? 単語全体 投稿 ユーザー 話題 作成したリスト リストを作成 リスト名 返信の表示範囲 表示しない リストメンバー フォロー中のすべての人 リストメンバー ホームのタイムラインでメンバーを非表示 重複表示を避けるため、リストのメンバーの投稿をホームのタイムラインで非表示にします。 このユーザーを削除しますか? ユーザー名を入力してください 未保存の変更があります。終了しますか? 検索内容を入力 このリストを削除しますか? 認証状態が異常です ホーム ローカル パブリック 言語: %1$s 月間アクティブ: %1$s 投稿 返信 メディア 概要 概要 サイトのルール 話題 直近 2 日で %1$s 人が議論中 人気 インスタンスを選択 インスタンス名または URL を入力してください ================================================ FILE: localization/src/commonMain/composeResources/values-ja-rJP/strings_status_ui.xml ================================================ リポスト済み リポスト 引用 いいね コメント 削除 共有 ブックマーク ブックマーク解除 返信 フォロー フォロー解除 固定 固定解除 編集 「%1$s」によりフィルターされました 新しい投稿 この内容を削除しますか? センシティブな内容 もっと見る 非表示にする 投票 %1$s 票 · 終了 %1$s 票 · 終了 ブラウザで開く 相手のインスタンスを開く リンクをコピー 翻訳 他のアカウントで開く メンションされたユーザーのみ 固定 編集済み %1$s 件のいいね %1$s 件のブースト %1$s に編集 翻訳中… 原文を表示 この内容を削除しますか? 内容名を編集 内容名を編集 内容の名前を入力してください ホームに表示されるリスト 長押ししてドラッグで並べ替え ホームに追加できるリスト 相互フォロー ブロック中 フォロー中 フォロー リクエスト中 フォローバック 相手があなたをフォローしようとしています フォローを解除しますか? フォローリクエストを取り消しますか? 相手のブロックを解除しますか? あなたをフォローしました 投稿 フォロワー フォロー中 スレッドを展開 アップデート バージョン %1$s 更新ログ:\n%2$s アカウントを選択 いいね ブックマーク プロフィールを編集 ログアウト ログアウトしますか? 検索する投稿を入力してください 不明な通知 あなたの投稿にいいねしました あなたの投稿をブーストしました 投票結果を見る %1$s 票 · 終了 あなたをフォローしました 投稿を編集しました フォローリクエストが届きました 新しい投稿を公開しました 関係を解除しました あなたの投稿を引用しました あなたの投稿に返信しました フォロー中のユーザー フォロワー ミュートしたユーザー ブロックしたユーザー いいねしたユーザー ブーストしたユーザー ミュート ブロック ミュート済み ブロック済み このユーザーをミュートしますか? このユーザーをブロックしますか? 投稿 考えていることを書いてください [[%1$s]] に返信 誰でも参加可能 制限付き 投稿の交流設定 誰がこの投稿と交流できるかを設定します。 引用設定 引用を許可 返信設定 返信を許可する範囲: 全員 自分のみ またはこれらを組み合わせ: メンションされたユーザー フォロー中のユーザー フォロワー 「%1$s」にいるユーザー 代替テキストを追加 詳しい説明文 代替テキスト ログイン情報が無効です。再度ログインしてください。 ログイン アカウントを選択してください 言語を選択 詳細 代替テキスト 現在の引用は利用できません 再共有を取り消す 誰でも フォロワーのみ 自分のみ 公開範囲 ================================================ FILE: localization/src/commonMain/composeResources/values-pt-rPT/strings.xml ================================================ Pular Alerta minuto hora dia semana Cancelar Sim Este espaço está vazio~ Definir duração Tentar novamente Ops! Não foi possível carregar. Erro desconhecido Erro de rede Recurso não encontrado Conteúdo misto · Fontes  atrás  atrás dia hora min seg Salvando… Salvo com sucesso Falha ao salvar A permissão de armazenamento foi negada. Ative-a nas configurações. Carregando conteúdo da página anterior Falha no carregamento, toque para tentar novamente Imagem Vídeo Entrar Adicionar Pesquisar Login bem-sucedido Falha no login Selecionar Sair Configurações Doar Feeds Salvar Tudo pronto! Bluesky Bluesky é uma rede aberta. Com uma única conta, você acessa uma rede social fácil de usar e uma identidade compartilhada em toda a internet social. Mastodon Mastodon é uma mídia social descentralizada e não comercial, onde os usuários controlam sua linha do tempo e cada servidor é uma entidade independente. Conteúdo misto O Feed Misto reúne todo o seu conteúdo. Adicione fontes do Mastodon, Bluesky, RSS e mais — o Fread combina tudo em uma linha do tempo cronológica. Mastodon Adicionar conteúdo Adicionado com sucesso. Conta atual: Adicionado com sucesso. Entrar agora? Ops! Este conteúdo já está aqui. Tudo certo! Seu conteúdo está pronto. Este nome de usuário já está em uso. Insira seu nome de usuário Nome Bio Insira seu nome de usuário Insira a bio Tem certeza de que deseja sair da edição? Adicionado com sucesso Insira o servidor no qual deseja entrar Selecione o servidor para entrar Host do servidor Insira o host do servidor Host do servidor Faça login O Fread permite adicionar várias contas. As contas adicionadas podem ser usadas para publicar, curtir, compartilhar, comentar etc. Você também pode ver as listas criadas por essas contas. Nenhum conteúdo disponível Público Público, mas fora da descoberta Apenas seguidores Somente pessoas mencionadas Aviso de conteúdo Enviado com sucesso Falha ao publicar: %1$s As alterações não salvas serão perdidas. Deseja sair? Insira o conteúdo Algumas contas não puderam publicar. As contas que publicaram com sucesso foram removidas. Tente novamente com as restantes: %1$s Selecionar uma conta Nenhuma outra conta Pesquisar em %1$s Falha na pesquisa Nenhum resultado encontrado Pesquisar Pesquisar em %1$s Contas Publicações Hashtags Servidor Explorar Posts Usuários Hashtags Adicionar servidor Confirmar adição Pesquisar Nenhum resultado encontrado Importar Selecione o arquivo OPML para importar Importar Adicionar conteúdo Nome do conteúdo Insira o nome do conteúdo Adicione uma fonte de assinatura Nenhum resultado encontrado Nome da instância, URL, página inicial do usuário, RSS O Fread permite adicionar [[Mastodon]]/[[Bluesky]], além de feeds [[RSS]] e até [[conteúdo misto]]. Insira aqui o [[NickName]] do Mastodon ou Bluesky, o [[WebFinger\Handle]], a [[URL da página inicial]] do usuário ou o URL do feed [[RSS]]. Adicione algum conteúdo primeiro Você precisa adicionar fontes de assinatura primeiro. O Fread suporta vários tipos de conteúdo e de diferentes plataformas, permitindo criar seu próprio feed de informações. Adicionar conteúdo Selecionar tipo de conteúdo Conteúdo adicionado com sucesso. Deseja entrar agora? Insira um nome Adicione uma fonte de assinatura Selecione o servidor de login Pesquisar NickName do Mastodon ou Bluesky, WebFinger, URL, RSS Selecione uma conta Insira o nome Nome Tem certeza de que deseja excluir estes feeds? Estado anormal, configuração não encontrada. Tem certeza de que deseja sair? Tem certeza de que deseja excluir? Escolha um formato O que você gostaria de adicionar? Notificações Nenhuma conta conectada no momento Tudo Menções Perfil Sobre Pague um café para apoiar este projeto. Reprodução automática de vídeos inline Se ativado, os vídeos nos Feeds serão reproduzidos automaticamente. Conteúdo sensível exibido por padrão Se ativado, o conteúdo sensível será exibido por padrão (apenas Mastodon). Modo escuro Modo escuro Modo claro Seguir o sistema Código aberto Licenças de código aberto das dependências usadas neste projeto Envie seu feedback Entre no grupo do Telegram ou por outros meios Entrar no grupo do Telegram Criar issue no GitHub Enviar e-mail Idioma de exibição 简体中文 English Seguir o sistema Avalie-nos Se você gostou, deixe-nos uma boa avaliação. Tamanho da fonte do conteúdo Ajuste o tamanho da fonte e dos ícones do conteúdo Pequeno Médio Grande Barra de navegação imersiva A barra de navegação inferior se oculta automaticamente ao rolar na página inicial. Posição padrão da linha do tempo Onde parei Publicações mais recentes Cor do tema Padrão Seguir o sistema Versão:  Site:  Criado por  Contato:  Grupo no Telegram:  Política de privacidade:  Verificar atualização Seu app já está atualizado Nova versão disponível. Toque para atualizar. Selecione como deseja doar Sessão expirada Atribuição de fonte Título personalizado Link de assinatura Página inicial Data de adição Última atualização Bluesky Entrar Provedor de hospedagem Nome de usuário ou e-mail Senha Código de confirmação Editar conteúdo Explorar mais feeds Por %1$s Curtido por %1$s usuários Por Por %1$s · Curtido por %2$s usuários Seguindo Posts Respostas Mídias Curtidas Usuários bloqueados Usuários silenciados Silenciar %1$s Reativar Bloquear %1$s Desbloquear Deseja bloquear este usuário? Deseja silenciar este usuário? Editar perfil Texto descritivo da imagem Insira o texto descritivo da imagem Novo blog Insira seu blog Texto descritivo Opção %1$s Duração da votação Função Única Múltipla Selecione o estilo Insira o conteúdo da votação Mídia ainda não enviada Esta função requer login Status de login inválido, entre novamente. Lista de conteúdo Conteúdo misto · Fontes Configurações Doar Notificação Recusar Permitir Para receber mensagens de interação, permita as notificações. Modo AMOLED Quando ativado, o fundo muda para preto puro no modo escuro para reduzir o consumo de energia e aumentar o contraste em ambientes escuros. Aparência Configurações de tema, exibição e estilo da Página inicial Comportamento Configurações de exibição de conteúdo e comportamento de interação Mostrar o botão para mudar de conteúdo no topo da Página inicial Mostrar no canto superior direito da Página inicial o botão para mudar para o conteúdo seguinte Mostrar o botão de atualizar no topo da Página inicial Mostrar no canto superior direito da Página inicial o botão para atualizar o conteúdo Abrir links no navegador do sistema Quando ativado, o Fread abrirá links no navegador do sistema. Fundo sólido das barras Torna as barras superior e inferior sólidas em vez de desfocadas. ================================================ FILE: localization/src/commonMain/composeResources/values-pt-rPT/strings_mastodon.xml ================================================ Entrou em Entrou em %1$s Bloquear %1$s Bloquear %1$s Desbloquear %1$s Editar nota Insira a nota privada Deseja bloquear este usuário? Deseja bloquear este domínio? Usuários bloqueados Usuários silenciados Silenciar %1$s Reativar %1$s Silenciar usuário? Eles não saberão que foram silenciados. Eles ainda podem ver suas postagens, mas você não verá as deles. Você não verá postagens que os mencionem. Eles podem mencioná-lo e segui-lo, mas você não os verá. Silenciar Adicionar instância Insira o endereço do servidor Insira o endereço correto do servidor Início Local Público Em alta %1$s posts · %2$s participantes · %3$s posts hoje Tem certeza de que deseja deixar de seguir esta hashtag? Sobre Configuração não encontrada Nenhum usuário por enquanto Nenhum usuário silenciado Reativar Nenhum usuário bloqueado Desbloquear Itens salvos Favoritos Hashtags seguidas Filtros Ativos Expirados Editar filtro Título Insira um título Duração Permanente 30 minutos 1 hora 12 horas 1 dia 3 dias 1 semana Personalizado Expira em %1$s Palavras-chave ocultas Editar palavra-chave Palavra-chave Tem certeza de que deseja excluir esta palavra-chave? Palavras silenciadas %1$s palavra ou frase silenciada Silenciar de Selecione as fontes para ocultar Início & listas Notificações Linhas do tempo públicas Tópicos & respostas Perfis Vazio Mostrar com aviso de conteúdo Ainda mostrar postagens que correspondem a este filtro, mas atrás de um aviso de conteúdo Tem certeza de que deseja excluir? Tem certeza de que deseja sair? Palavra inteira Posts Usuários Hashtags Listas Criar lista Nome da lista Mostrar respostas para Ninguém Membros da lista Qualquer um que eu sigo Membros da lista Ocultar membros em Seguindo Se alguém estiver nesta lista, oculte-o na sua linha do tempo de Seguindo para evitar ver as postagens duas vezes. Tem certeza de que deseja remover este usuário? Insira seu nome de usuário Você tem alterações não salvas. Deseja sair? Digite para pesquisar Tem certeza de que deseja excluir esta lista? Exceção de estado do OAuth! Início Local Público Idioma: %1$s Ativos no mês: %1$s Posts Respostas Mídia Sobre Sobre Regras Tags em alta %1$s pessoas nos últimos 2 dias Em alta Escolha uma instância Insira o nome ou URL da instância ================================================ FILE: localization/src/commonMain/composeResources/values-pt-rPT/strings_status_ui.xml ================================================ impulsionado Impulsionar Citar Curtir Comentar Excluir Compartilhar Salvar Remover dos salvos Responder Seguir Deixar de seguir Fixar Desafixar Editar Corresponde ao filtro "%1$s" Novo conteúdo Tem certeza de que deseja excluir este conteúdo? Mídia oculta Mostrar mais Ocultar conteúdo Votar %1$s voto · Encerrado %1$s votos · Encerrado Abrir no navegador Abrir a outra instância Copiar link Traduzir Abrir com outra conta Somente mencionados Fixado Editado %1$s favoritos %1$s impulsionamentos Editado em %1$s Traduzindo… Mostrar original Confirmar exclusão deste conteúdo? Editar nome do conteúdo Editar nome do conteúdo Digite o nome do conteúdo Feeds na página inicial Toque e segure para arrastar e reordenar Feeds que podem ser adicionados à página inicial Mútuos Bloqueado Seguindo Seguir Pendente Seguir de volta Este usuário pediu para segui-lo Deseja deixar de seguir este usuário? Deseja cancelar seu pedido de seguir? Tem certeza de que deseja desbloquear este usuário? Segue você Postagens Seguidores Seguindo Tópico continuado Atualização Versão %1$s - Registro de alterações:\n%2$s Selecionar conta Curtidas Salvos Editar perfil Sair Tem certeza de que deseja sair? Pesquisar postagens Notificação desconhecida curtiu sua postagem repostou sua postagem Ver resultados anteriores da votação %1$s votos · Encerrado começou a seguir você editou uma postagem Você recebeu um pedido de seguir publicou um novo blog rompeu vínculos com você citou sua postagem respondeu à sua postagem Seguindo Seguidores Usuários silenciados Usuários bloqueados Curtidas Impulsionamentos Silenciar Bloquear Silenciado Bloqueado Tem certeza de que deseja silenciar este usuário? Tem certeza de que deseja bloquear este usuário? Postar Escreva ou cole o que está pensando Respondendo a [[%1$s]] Qualquer pessoa pode interagir Interação limitada Configurações de interação da postagem Personalize quem pode interagir com esta postagem. Configurações de citação Permitir citações Configurações de resposta Permitir respostas de: Todos Ninguém Ou combine estas opções: Usuários mencionados Usuários que você segue Seus seguidores Usuários na lista "%1$s" Adicionar texto alternativo Texto alternativo descritivo Texto alternativo Ops! Sua sessão expirou. Faça login novamente para continuar. Entrar Selecione uma conta Selecionar idioma Detalhe ALT A citação atual não está disponível Cancelar encaminhamento Qualquer pessoa Apenas seguidores Só eu Visibilidade ================================================ FILE: localization/src/commonMain/composeResources/values-ru-rRU/strings.xml ================================================ Пропустить Оповещение минута час день неделя Отмена Да Здесь пусто~ Установить длительность Повторить Упс! Не удалось загрузить. Неизвестная ошибка Ошибка сети Ресурс не найден Смешанный контент · Источники  назад  назад день час мин с Сохранение… Успешно сохранено Ошибка сохранения Доступ к хранилищу отклонён, пожалуйста, включите его в настройках. Загрузка содержимого предыдущей страницы Не удалось загрузить, нажмите, чтобы повторить Изображение Видео Войти Добавить Поиск Вход выполнен Не удалось войти Выбрать Выйти Настройки Пожертвовать Ленты Сохранить Готово! Bluesky Bluesky — это открытая сеть. С одной учётной записью вы получаете доступ к удобной социальной сети и единой идентичности во всей социальной сети Интернет. Mastodon Mastodon — децентрализированная, некоммерческая социальная сеть, где пользователи управляют своей лентой, а каждый сервер является независимой сущностью. Смешанный контент Смешанная лента объединяет весь ваш контент. Добавляйте источники из Mastodon, Bluesky, RSS и др. — Fread соберёт их в единую хронологическую ленту. Mastodon Добавить контент Успешно добавлено. Текущий аккаунт: Успешно добавлено. Войти сейчас? Упс! Этот контент уже добавлен. Готово! Ваш контент настроен. Это имя пользователя уже занято. Пожалуйста, введите имя пользователя Имя Био Пожалуйста, введите имя пользователя Пожалуйста, введите био Вы уверены, что хотите выйти из режима редактирования? Успешно добавлено Укажите сервер для входа Выберите сервер для входа Введите хост сервера Введите хост сервера Хост сервера Выполните вход Fread поддерживает добавление нескольких аккаунтов. С добавленными аккаунтами вы можете публиковать посты, ставить лайки, делиться, комментировать и просматривать списки, созданные этими аккаунтами. Контент отсутствует Публично Публично, но исключено из обнаружения Только для подписчиков Только упомянутые Предупреждение о контенте Отправлено Не удалось отправить: %1$s Несохранённые изменения будут потеряны. Выйти? Пожалуйста, введите содержимое Некоторые аккаунты не удалось опубликовать. Успешно опубликованные аккаунты удалены из очереди. Повторите попытку для оставшихся: %1$s Выберите аккаунт Других аккаунтов нет Искать в %1$s Поиск не удался Ничего не найдено Поиск Поиск в %1$s Аккаунты Статусы Хэштеги Сервер Обзор Посты Пользователи Хэштеги Добавить сервер Подтвердить Поиск Результатов не найдено Импорт Выберите файл OPML для импорта Импорт Добавить контент Название контента Введите название контента Добавьте источник подписки Ничего не найдено Имя инстанса, URL, домашняя страница пользователя, RSS Fread поддерживает добавление лент [[Mastodon]]/[[Bluesky]], а также [[RSS]] и даже [[смешанного контента]]. Введите здесь [[NickName]] Mastodon или Bluesky, [[WebFinger\Handle]], [[домашнюю страницу]] пользователя или URL ленты [[RSS]]. Сначала добавьте контент Сначала нужно добавить источники подписки. Fread поддерживает различные типы контента и платформы, позволяя собрать собственную информационную ленту. Добавить контент Выберите тип контента Контент добавлен. Войти сейчас? Введите имя Добавьте источник Выберите сервер для входа Поиск Mastodon или Bluesky NickName, WebFinger, URL, RSS Пожалуйста, выберите аккаунт Введите имя Имя Удалить эту ленту? Аномальное состояние: конфигурация не найдена. Выйти? Удалить? Выберите формат Что вы хотите добавить? Уведомления Нет активных аккаунтов Все Упоминания Профиль О приложении Угостите меня кофе, чтобы поддержать проект. Автовоспроизведение встроенного видео Если включено, видео в лентах будет запускаться автоматически. Показывать чувствительный контент по умолчанию Если включено, чувствительный контент будет отображаться по умолчанию (только Mastodon). Тёмная тема Тёмная Светлая Как в системе Открытый исходный код Лицензии открытого ПО, используемого в проекте Оставить отзыв Присоединяйтесь к группе Telegram или свяжитесь иным способом Присоединиться к группе Telegram Создать задачу на GitHub Отправить письмо Язык отображения 简体中文 English Как в системе Оцените нас Если вам нравится приложение, поставьте, пожалуйста, высокую оценку. Размер шрифта контента Настроить размер шрифта и значков Малый Средний Крупный Иммерсивная навигационная панель При прокрутке главной страницы нижняя панель будет автоматически скрываться. Позиция ленты по умолчанию Где остановился Самые новые посты Цвет темы По умолчанию Как в системе Версия:  Сайт:  Создатель:  Свяжитесь со мной:  Группа в Telegram:  Политика конфиденциальности:  Проверить обновление У вас установлена последняя версия Доступна новая версия, нажмите для обновления. Выберите способ пожертвования Сессия истекла Источник Пользовательский заголовок Ссылка на подписку Домашняя страница Дата добавления Последнее обновление Bluesky Войти Хостинг-провайдер Имя пользователя или e-mail Пароль Код подтверждения Редактировать контент Посмотреть больше лент От %1$s Понравилось %1$s пользователям От От %1$s · Понравилось %2$s пользователям Подписки Посты Ответы Медиа Лайки Заблокированные пользователи Скрытые пользователи Скрыть %1$s Отменить скрытие Заблокировать %1$s Разблокировать Заблокировать этого пользователя? Скрыть этого пользователя? Редактировать профиль Описание изображения Введите описание изображения Новый блог Введите свой пост Описание Вариант %1$s Длительность голосования Функция Один вариант Несколько вариантов Выберите стиль Введите содержание голосования Медиа ещё не загружены Для использования этой функции необходимо войти Статус входа недействителен, войдите снова. Список контента Смешанный контент · Источники Настройки Пожертвовать Уведомление Отклонить Разрешить Чтобы получать сообщения об интеракциях, разрешите уведомления. Режим AMOLED При включении фон в тёмном режиме становится полностью чёрным, что снижает энергопотребление и повышает контраст в тёмных условиях. Внешний вид Настройки темы, отображения и стиля главной страницы Поведение Настройки отображения контента и поведения взаимодействия Показывать кнопку переключения контента вверху главной Показывать в правом верхнем углу главной кнопку для перехода к следующему контенту Показывать кнопку обновления вверху главной Показывать в правом верхнем углу главной кнопку для обновления контента Открывать ссылки в системном браузере При включении Fread будет открывать ссылки в системном браузере. Сплошной фон панелей Делает верхнюю и нижнюю панели сплошными вместо размытого фона. ================================================ FILE: localization/src/commonMain/composeResources/values-ru-rRU/strings_mastodon.xml ================================================ Дата регистрации Дата регистрации: %1$s Заблокировать %1$s Заблокировать %1$s Разблокировать %1$s Изменить заметку Введите личную заметку Заблокировать этого пользователя? Заблокировать этот домен? Заблокированные пользователи Скрытые пользователи Скрыть %1$s Отменить скрытие %1$s Скрыть пользователя? Они не узнают, что их скрыли. Они по-прежнему смогут видеть ваши посты, но вы не будете видеть их. Вы не будете видеть посты, в которых их упоминают. Они смогут упоминать и подписываться на вас, но вы их не увидите. Скрыть Добавить инстанс Введите адрес сервера Введите корректный адрес сервера Главная Локальная Публичная В тренде %1$s публикаций · %2$s участников · %3$s публикаций сегодня Отписаться от этого хэштега? О себе Конфигурация не найдена Пока нет пользователей Нет скрытых пользователей Отменить скрытие Нет заблокированных пользователей Разблокировать Закладки Избранное Подписки на хэштеги Фильтры Активные Истёкшие Редактировать фильтр Заголовок Введите заголовок Длительность Постоянно 30 минут 1 час 12 часов 1 день 3 дня 1 неделя Другое Действительно до %1$s Скрытые ключевые слова Редактировать ключевое слово Ключевое слово Удалить это ключевое слово? Скрытые слова %1$s скрытых слов или фраз Скрывать в Выберите источники для скрытия Главная & списки Уведомления Публичные ленты Темы & ответы Профили Пусто Показывать с предупреждением о содержимом Показывать посты, подходящие под фильтр, но с предупреждением о содержимом Удалить? Выйти? Целое слово Посты Пользователи Хэштеги Списки Создать список Название списка Показывать ответы Никому Участникам списка Любому, на кого я подписан Участники списка Скрывать участников в «Подписках» Если кто-то в этом списке, скрывать его посты в ленте «Подписки», чтобы не видеть их дважды. Удалить этого пользователя? Введите имя пользователя Есть несохранённые изменения. Выйти? Введите для поиска Удалить этот список? Ошибка состояния OAuth! Главная Локальная Публичная Язык: %1$s Активных за месяц: %1$s Посты Ответы Медиа О себе О себе Правила Трендовые теги За последние 2 дня: %1$s человек Популярное Выберите инстанс Введите имя инстанса или URL ================================================ FILE: localization/src/commonMain/composeResources/values-ru-rRU/strings_status_ui.xml ================================================ повторно опубликовано Репост Цитата Нравится Комментарий Удалить Поделиться Закладка Убрать из закладок Ответить Подписаться Отписаться Закрепить Открепить Редактировать Соответствует фильтру "%1$s" Новый контент Удалить этот контент? Медиа скрыто Показать больше Скрыть контент Голосовать %1$s голос · Закрыто %1$s голосов · Закрыто Открыть в браузере Открыть другой инстанс Копировать ссылку Перевести Открыть с другого аккаунта Только упомянутые Закреплено Отредактировано %1$s отметок «Нравится» %1$s репостов Отредактировано %1$s Перевод… Показать оригинал Удалить этот контент? Редактировать название контента Редактировать название Введите название контента Ленты на главной Нажмите и удерживайте, затем перетащите для сортировки Ленты для добавления на главную Взаимные Заблокирован Подписан Подписаться В ожидании Подписаться в ответ Этот пользователь хочет подписаться на вас Отписаться от этого пользователя? Отменить запрос на подписку? Разблокировать этого пользователя? Подписан на вас Посты Подписчики Подписки Продолжение ветки Обновление Журнал изменений версии %1$s:\n%2$s Выберите аккаунт Лайки Закладки Редактировать профиль Выйти Выйти из аккаунта? Поиск постов Неизвестное уведомление понравился ваш пост поделился вашим постом Посмотреть результаты голосования %1$s голосов · Закрыто подписался на вас отредактировал пост Вы получили запрос на подписку опубликовал новый пост разорвал связь с вами процитировал ваш пост ответил на ваш пост Подписки Подписчики Скрытые пользователи Заблокированные пользователи Понравилось Репосты Скрыть Заблокировать Скрыт Заблокирован Скрыть этого пользователя? Заблокировать этого пользователя? Пост Напишите или вставьте, что у вас на уме Ответ для [[%1$s]] Все могут взаимодействовать Взаимодействие ограничено Настройки взаимодействия с постом Настройте, кто может взаимодействовать с этим постом. Настройки цитирования Разрешить цитирование Настройки ответов Разрешить ответы от: Всех Никого Или комбинируйте эти варианты: Упомянутые пользователи Пользователи, на которых вы подписаны Ваши подписчики Пользователи из "%1$s" Добавить alt-текст Описание для alt-текста Alt-текст Ваша сессия истекла. Пожалуйста, войдите снова. Войти Выберите аккаунт Выберите язык Детали ALT Текущая цитата недоступна Отменить пересылку Все Только подписчики Только я Видимость ================================================ FILE: localization/src/commonMain/composeResources/values-zh-rCN/strings.xml ================================================ 跳过 提示 分钟 小时 取消 确定 这里是空的~ 设置时长 重试 加载失败。 未知错误 网络错误 资源未找到 混合内容 · 条订阅源 小时 保存中… 保存成功 保存失败 存储权限被拒绝,请到设置中打开。 正在加载上一页内容 加载失败,点击重试 图片 视频 登录 添加 搜索 登录成功 登录失败 选择 退出登录 设置 捐赠 Feeds 保存 完成 Bluesky Bluesky 是一个开放网络。通过一个账户,你既可以访问易用的社交网络,也可以在整个社交互联网上拥有共享身份。 长毛象 Mastodon 是一个去中心化、非商业化的社交媒体,用户可以控制自己的时间线,每个服务器都是独立的实体。 混合内容 混合内容是由 Fread 提供的自定义聚合信息流,你可以添加来自长毛象、Bluesky、RSS 等平台的信息源,来自这些信息源的内容会按照时间倒序形成一个聚合信息流。 长毛象 添加内容 添加成功,当前账号: 添加成功,是否登录? 该内容已存在 内容添加成功 该名字已存在 请输入用户名 名字 简介 请输入用户名 请输入简介 确定退出编辑吗? 添加成功 请输入要登录的服务器 请选择要登录的服务器 请输入服务器地址 请输入服务器地址 服务器地址 请登录 Fread 支持添加多个账号,添加进来的账号可以用来发帖、点赞、转发、评论等操作,还可以查看已添加账号创建的列表。 暂无内容 公开 公开,但不在探索功能出现 仅关注者可见 仅提及用户可见 警告文本 发送成功 发送失败:%1$s 退出后已编辑的内容不会被保存,确定退出吗? 请输入内容 部分账号发布失败,已从发布队列移除成功的账号,请重新尝试:%1$s 请选择账号 暂无其它账号 在 %1$s 内查找 查找失败 未搜索到结果 搜索 搜索 %1$s 用户 帖子 标签 服务器 探索 帖子 用户 话题 添加服务 确定添加 搜索 未搜索到任何结果 导入 请选择要导入的 OPML 文件 导入 添加内容 内容名 请输入内容名 请添加订阅源 未搜索到任何结果 实例名、实例地址,用户地址、RSS Fread 支持添加[[长毛象]]、[[Bluesky]]以及[[ RSS ]]内容,甚至是[[混合内容]]。 请直接在此处输入长毛象或 Bluesky [[用户名]]、[[WebFinger\Handle]],用户[[首页地址]]或者 [[RSS]] 地址。 请先添加内容 需要先添加内容的订阅源哦,Fread 支持多种内容类型,以及不同平台的混合内容,可以打造您自己的信息流。 添加内容 请选择内容类型 添加成功,是否登录? 请输入一个名字 请添加信息源 请选择要登录的服务器 搜索 长毛象或 Bluesky 用户名、WebFinger、URL、RSS 请选择一个账号 请输入内容名称 名称 您确定要删除这个内容吗? 状态异常,未找到配置 确定要退出吗? 确定要删除吗? 请选择内容类型 请选择您要添加的内容类型 通知 当前没有已登录账号 所有 提及 账号 关于 给我买杯咖啡以此支持这个项目 Inline 视频自动播放 开启后,Feeds 流中的视频将会自动播放。 敏感内容默认显示 开启后,敏感内容默认会显示(仅 Mastodon 平台)。 深色模式 深色模式 浅色模式 跟随系统 开源声明 本项目使用到的依赖库的开源声明 给我们反馈 加入 Telegram 群组或者其它方式 加入 Telegram 群组 创建 GitHub issue 发送邮件 显示语言 简体中文 English 跟随系统 给我们评价 如果觉得不错请到应用市场给我们一个好评 内容字体大小 调整内容的字体和图标大小 沉浸式导航栏 打开后,首页滑动时会隐藏底部的导航栏。 首页的时间线默认位置 上次阅读位置 最新位置 主题色 默认 系统 版本: 官网: 开发者: 联系我: Telegram 群组: 隐私协议: 检查更新 当前版本已经是最新了 有新版本,点击更新。 请选择捐赠方式 登陆已失效 信息源属性 自定义标题 订阅地址 主页 添加日期 上次更新时间 Bluesky 登录 服务商地址 用户名或邮箱 密码 验证码 编辑 浏览更多 Feeds 创建者 %1$s %1$s 个用户喜欢 作者 创建者 %1$s · %2$s 个用户喜欢 关注中 帖子 回复 媒体 喜欢 屏蔽的用户 隐藏的用户 隐藏 %1$s 取消隐藏 屏蔽 %1$s 取消屏蔽 您确认要屏蔽对方吗? 您确认要隐藏对方吗? 编辑个人资料 描述文本 请输入描述文本 新博客 请输入你的博客 描述文本 选项 %1$s 投票时长 能力 单选 多选 请选择功能 请输入投票内容 媒体暂未上传 该功能需要登录后才能使用哦 登录状态失效,请重新登录。 内容列表 混合内容 · 条订阅源 设置 捐赠 通知权限 拒绝 允许 为了接收互动消息,请允许通知权限。 Amoled 模式 启用该功能后,深色模式下会将背景切换为纯黑,以降低能耗并在暗色环境中获得更高对比度。 外观 主题、显示与首页样式设置 行为 内容展示与交互行为设置 首页顶部的切换内容按钮显示 首页的右上角是否显示用于切换到下一个内容的按钮 首页顶部的刷新按钮显示 首页的右上角是否显示用于刷新内容的按钮 通过系统浏览器打开链接 启用后,Fread 将通过系统浏览器打开链接。 顶部和底部栏纯色背景 将顶部和底部栏改为纯色,而不是高斯模糊。 ================================================ FILE: localization/src/commonMain/composeResources/values-zh-rCN/strings_mastodon.xml ================================================ 加入时间 %1$s 加入 屏蔽 %1$s 屏蔽 %1$s 取消屏蔽 %1$s 编辑备注 请输入备注 您确认要屏蔽对方吗? 您确认要屏蔽对方实例吗? 屏蔽的用户 隐藏的用户 隐藏 %1$s 取消隐藏 %1$s 隐藏用户? 他们不会知道自己被隐藏 他们仍然可以看到你的帖子,但是你不会看到他们的帖子。 你将不会看到提及他们的帖子。 他们可以提及和关注你,但是你不会看到他们。 隐藏 添加实例 请输入服务器地址 请输入正确的服务器地址 主页 本站 跨站 趋势 %1$s 条帖子 · %2$s 参与 · %3$s 条今日帖子 您确定要取消关注这个话题吗? 关于 配置未找到 暂无用户 暂无隐藏的用户 Unmute 暂无屏蔽的用户 Unblock 收藏的内容 点赞的内容 关注的话题 过滤器 生效中 已失效 编辑过滤器 标题 请输入标题 持续时间 永久 三十分钟 一小时 十二小时 一天 三天 一周 自定义 结束与 %1$s 已隐藏的关键字 编辑关键字 关键字 确定删除关键字吗? 已隐藏关键字 %1$s 个已隐藏的关键字 隐藏来源 请选择隐藏来源 主页 & 列表 通知 公共时间线 嘟文 & 回复 个人资料 包含有内容警告的内容 仍然显示符合该过滤器的嘟文,但会加上内容警告 确定删除吗? 确定退出吗? 整个词条 嘟文 用户 话题 创建的列表 创建列表 列表名称 回复显示范围 不显示 列表成员 我关注的所有人 列表成员 在主页时间线中隐藏成员 列表成员的嘟文将不会在你的主页时间线中显示以免重复阅读。 确定移除该用户? 请输入用户名 当前还有更改尚未保存,确认退出吗? 请输入搜索内容 确定删除这个列表吗? 认证状态异常 主页 本站 跨站 语言: %1$s 月活: %1$s 帖子 回复 媒体 关于 关于 站点规则 话题 最近两天有%1$s人在讨论 热门 选择一个实例 请输入实例名或者实例URL ================================================ FILE: localization/src/commonMain/composeResources/values-zh-rCN/strings_status_ui.xml ================================================ 已转发 转发 引用 喜欢 评论 删除 分享 收藏 取消收藏 回复 关注 取消关注 置顶 取消置顶 编辑 内容已被“%1$s”过滤 新内容 确定要删除这条内容吗? 敏感内容 显示更多 隐藏内容 投票 %1$s 票 · 已关闭 %1$s 票 · 已关闭 在浏览器中打开 打开对方实例 复制链接 翻译 使用其它账号打开 仅提及用户可见 置顶 已编辑 %1$s 次喜欢 %1$s 次转发 编辑与 %1$s 翻译中… 显示原文 确定删除这个内容? 修改内容名字 编辑内容名 请输入内容的名字 显示在首页中的列表 可以长按拖动排序 可添加到首页的列表 互相关注 已屏蔽 已关注 关注 请求中 回关 对方正在请求关注你 您确认要取消关注对方吗? 您确定要撤回关注请求吗? 您确定要取消屏蔽对方吗? 关注了你 帖子 关注者 正在关注 展开对话 更新 %1$s 版本更新日志:\n%2$s 选择账号 喜欢 收藏 修改个人资料 退出登录 你确定要退出登录吗? 请输入你要搜索的帖子 未知通知 喜欢了你的动态 转发了你的动态 查看投票结果 %1$s 票 · 已结束 关注了你 编辑了一条帖子 你收到一条关注请求 发布了新帖子 和你中断了关系 引用了你的帖子 回复了你的帖子 正在关注的用户 关注你的用户 隐藏的用户 屏蔽的用户 点赞的用户 转发的用户 隐藏 屏蔽 已隐藏 已屏蔽 您确定要隐藏该用户吗? 您确定要屏蔽该用户吗? 发帖 写下你的想法 回复给 [[%1$s]] 任何人都可以参与互动 已限制互动 帖子互动选项 自定义哪些人可以参与这个帖子的互动。 引用选项 允许引用帖子 回复选项 允许这些人参与回复: 所有人 仅限自己 或者组合选择这些选项: 被提及的用户 你关注的用户 你的关注者 \"%1$s\"中的用户 添加替代文本 扩充描述替代文本 替代文本 登录信息已失效,请重新登陆 登录 请选择账号 选择语言 详情 替代文本 当前引用不可用 取消转发 任何人 仅关注者 仅限自己 可见性 ================================================ FILE: localization/src/commonMain/composeResources/values-zh-rHK/strings.xml ================================================ 跳過 提示 分鐘 小時 取消 確定 這裡是空的~ 設定時長 重試 載入失敗。 未知錯誤 網絡錯誤 資源未找到 混合內容 · 個訂閱源 小時 儲存中… 儲存成功 儲存失敗 儲存權限被拒絕,請到設定中開啟。 正在載入上一頁內容 載入失敗,點擊重試 圖片 影片 登入 新增 搜尋 登入成功 登入失敗 選擇 登出 設定 捐贈 Feeds 儲存 完成 Bluesky Bluesky 是一個開放網絡。透過一個帳戶,你既可使用易上手的社交網絡,亦可在整個社交互聯網上擁有共享身分。 長毛象 Mastodon 是一個去中心化、非商業化的社交媒體,用戶可以掌控自己的時間線,每個伺服器都是獨立實體。 混合內容 混合內容是由 Fread 提供的自訂聚合資訊流,你可新增來自長毛象、Bluesky、RSS 等平台的來源,這些來源的內容會按時間倒序聚合成一條資訊流。 長毛象 新增內容 新增成功,目前帳戶: 新增成功,是否登入? 該內容已存在 內容新增成功 該名稱已存在 請輸入用戶名稱 名稱 簡介 請輸入用戶名稱 請輸入簡介 確定要退出編輯嗎? 新增成功 請輸入要登入的伺服器 請選擇要登入的伺服器 請輸入伺服器地址 請輸入伺服器地址 伺服器地址 請登入 Fread 支援新增多個帳戶,已新增的帳戶可用於發文、讚好、轉發、留言等操作,亦可查看已新增帳戶建立的清單。 暫無內容 公開 公開,但不在探索中顯示 僅追隨者可見 僅被提及用戶可見 警告文字 發送成功 發送失敗:%1$s 退出後已編輯的內容不會被儲存,確定要退出嗎? 請輸入內容 部分帳戶發佈失敗,已從佇列移除成功的帳戶,請重新嘗試:%1$s 請選擇帳戶 暫無其他帳戶 在 %1$s 內搜尋 搜尋失敗 未搜尋到結果 搜尋 搜尋 %1$s 用戶 貼文 標籤 伺服器 探索 貼文 用戶 話題 新增服務 確定新增 搜尋 未搜尋到任何結果 匯入 請選擇要匯入的 OPML 檔案 匯入 新增內容 內容名稱 請輸入內容名稱 請新增訂閱源 未搜尋到任何結果 實例名稱、實例地址、用戶地址、RSS Fread 支援新增[[長毛象]]、[[Bluesky]]以及[[ RSS ]]內容,甚至是[[混合內容]]。 請直接在此輸入長毛象或 Bluesky 的[[用戶名稱]]、[[WebFinger\Handle]]、用戶[[主頁地址]]或 [[RSS]] 地址。 請先新增內容 你需要先新增內容的訂閱源。Fread 支援多種內容類型與跨平台混合內容,打造你自己的資訊流。 新增內容 請選擇內容類型 新增成功,是否登入? 請輸入一個名稱 請新增資訊來源 請選擇要登入的伺服器 搜尋 長毛象或 Bluesky 用戶名、WebFinger、URL、RSS 請選擇一個帳戶 請輸入內容名稱 名稱 你確定要刪除這個內容嗎? 狀態異常,未找到設定 確定要退出嗎? 確定要刪除嗎? 請選擇內容類型 請選擇你要新增的內容類型 通知 目前沒有已登入帳戶 所有 提及 帳戶 關於 請我飲杯咖啡,以支持此項目 Inline 影片自動播放 開啟後,Feeds 流中的影片將自動播放。 敏感內容預設顯示 開啟後,敏感內容會預設顯示(僅限 Mastodon 平台)。 深色模式 深色模式 淺色模式 跟隨系統 開源聲明 本項目所使用依賴庫之開源聲明 向我們回饋 加入 Telegram 群組或其他方式 加入 Telegram 群組 建立 GitHub issue 發送電郵 顯示語言 簡體中文 English 跟隨系統 為我們評分 如果覺得不錯,請到應用商店給我們好評 內容字體大小 調整內容的字體與圖示大小 沉浸式導航欄 開啟後,在主頁捲動時會隱藏底部導航欄。 主頁時間線預設位置 上次閱讀位置 最新位置 主題色 預設 系統 版本: 官網: 開發者: 聯絡我: Telegram 群組: 私隱政策: 檢查更新 目前已是最新版本 有新版本,點擊更新。 請選擇捐贈方式 登入已失效 資訊源屬性 自訂標題 訂閱地址 主頁 新增日期 上次更新時間 Bluesky 登入 服務商地址 用戶名或電郵 密碼 驗證碼 編輯 瀏覽更多 Feeds 建立者 %1$s %1$s 位用戶讚好 作者 建立者 %1$s · %2$s 位用戶讚好 追蹤中 貼文 回覆 媒體 讚好 已封鎖用戶 已隱藏用戶 隱藏 %1$s 取消隱藏 封鎖 %1$s 取消封鎖 你確認要封鎖對方嗎? 你確認要隱藏對方嗎? 編輯個人資料 描述文字 請輸入描述文字 新貼文 請輸入你的貼文 描述文字 選項 %1$s 投票時長 功能 單選 多選 請選擇功能 請輸入投票內容 媒體暫未上傳 此功能需登入後方可使用 登入狀態已失效,請重新登入。 內容清單 混合內容 · 個訂閱源 設定 捐贈 通知權限 拒絕 允許 為接收互動訊息,請允許通知權限。 Amoled 模式 啟用此功能後,深色模式下會把背景變成純黑,以減低耗電量,並在昏暗環境中提升對比度。 外觀 主題、顯示與首頁樣式設定 行為 內容顯示與互動行為設定 首頁頂部的切換內容按鈕顯示 首頁右上角是否顯示用於切換到下一個內容的按鈕 首頁頂部的重新整理按鈕顯示 首頁右上角是否顯示用於重新整理內容的按鈕 透過系統瀏覽器開啟連結 啟用後,Fread 會透過系統瀏覽器開啟連結。 頂部和底部欄純色背景 將頂部和底部欄改為純色,而不是高斯模糊。 ================================================ FILE: localization/src/commonMain/composeResources/values-zh-rHK/strings_mastodon.xml ================================================ 加入時間 %1$s 加入 封鎖 %1$s 封鎖 %1$s 取消封鎖 %1$s 編輯備註 請輸入備註 你確認要封鎖對方嗎? 你確認要封鎖對方實例嗎? 已封鎖用戶 已隱藏用戶 隱藏 %1$s 取消隱藏 %1$s 隱藏用戶? 他們不會知道自己被隱藏 他們仍可看到你的貼文,但你不會看到他們的貼文。 你將不會看到提及他們的貼文。 他們可以提及並追蹤你,但你不會看到他們。 隱藏 新增實例 請輸入伺服器地址 請輸入正確的伺服器地址 主頁 本站 跨站 趨勢 %1$s 則貼文 · %2$s 人參與 · 今日 %3$s 則貼文 你確定要取消追蹤這個話題嗎? 關於 找不到設定 暫無用戶 暫無已隱藏的用戶 取消隱藏 暫無已封鎖的用戶 取消封鎖 收藏的內容 讚好的內容 追蹤的話題 過濾器 生效中 已失效 編輯過濾器 標題 請輸入標題 持續時間 永久 三十分鐘 一小時 十二小時 一天 三天 一週 自訂 結束於 %1$s 已隱藏關鍵字 編輯關鍵字 關鍵字 確定刪除關鍵字嗎? 已隱藏關鍵字 %1$s 個已隱藏的關鍵字 隱藏來源 請選擇隱藏來源 主頁 & 清單 通知 公共時間線 嘟文 & 回覆 個人資料 包含內容警告的內容 仍會顯示符合此過濾器的嘟文,但會加上內容警告 確定要刪除嗎? 確定要退出嗎? 整個詞條 嘟文 用戶 話題 建立的清單 建立清單 清單名稱 回覆顯示範圍 不顯示 清單成員 我追蹤的所有人 清單成員 在主頁時間線中隱藏成員 清單成員的嘟文將不會顯示在你的主頁時間線中,以免重複閱讀。 確定移除該用戶? 請輸入清單名稱 目前仍有變更未儲存,確認要退出嗎? 請輸入搜尋內容 確定要刪除這個清單嗎? 認證狀態異常 主頁 本站 跨站 語言:%1$s 月活躍:%1$s 貼文 回覆 媒體 關於 關於 站點規則 話題 最近兩天有 %1$s 人在討論 熱門 選擇一個實例 請輸入實例名稱或實例 URL ================================================ FILE: localization/src/commonMain/composeResources/values-zh-rHK/strings_status_ui.xml ================================================ 已轉貼 轉貼 引用 讚好 評論 刪除 分享 收藏 取消收藏 回覆 追蹤 取消追蹤 置頂 取消置頂 編輯 內容已被「%1$s」過濾 新內容 確定要刪除這則內容嗎? 敏感內容 顯示更多 隱藏內容 投票 %1$s 票 · 已關閉 %1$s 票 · 已關閉 在瀏覽器中開啟 開啟對方實例 複製連結 翻譯 使用其他帳戶開啟 僅提及用戶可見 置頂 已編輯 %1$s 次讚好 %1$s 次轉發 編輯於 %1$s 翻譯中… 顯示原文 確定刪除這個內容? 修改內容名稱 編輯內容名稱 請輸入內容名稱 顯示在主頁的清單 可長按拖動排序 可新增到主頁的清單 互相關注 已封鎖 已追蹤 追蹤 請求中 回追 對方正在請求追蹤你 你確認要取消追蹤對方嗎? 你確定要撤回追蹤請求嗎? 你確定要取消封鎖對方嗎? 追蹤了你 貼文 追蹤者 正在追蹤 展開對話 更新 %1$s 版本更新日誌:\n%2$s 選擇帳戶 讚好 收藏 修改個人資料 登出 你確定要登出嗎? 請輸入你要搜尋的貼文 未知通知 讚好了你的動態 轉發了你的動態 查看投票結果 %1$s 票 · 已結束 追蹤了你 編輯了一則貼文 你收到一則追蹤請求 發佈了新貼文 和你中斷了關係 引用了你的貼文 回覆了你的貼文 正在追蹤的用戶 追蹤你的用戶 已隱藏用戶 已封鎖用戶 讚好的用戶 轉發的用戶 隱藏 封鎖 已隱藏 已封鎖 你確定要隱藏該用戶嗎? 你確定要封鎖該用戶嗎? 發文 寫下你的想法 回覆給 [[%1$s]] 任何人都可以互動 已限制互動 貼文互動選項 自訂哪些人可以互動這則貼文。 引用選項 允許引用貼文 回覆選項 允許這些人回覆: 所有人 僅限自己 或者組合選擇這些選項: 被提及的用戶 你追蹤的用戶 你的追蹤者 「%1$s」中的用戶 新增替代文字 補充描述替代文字 替代文字 登入資訊已失效,請重新登入 登入 請選擇帳戶 選擇語言 詳情 替代文字 當前引用不可用 取消轉發 任何人 只限追蹤者 只限自己 可見性 ================================================ FILE: localization/src/commonMain/composeResources/values-zh-rTW/strings.xml ================================================ 跳過 提示 分鐘 小時 取消 確定 這裡是空的~ 設定時長 重試 載入失敗。 未知錯誤 網路錯誤 資源未找到 混合內容 · 條訂閱源 小時 儲存中… 儲存成功 儲存失敗 儲存權限被拒絕,請到設定中開啟。 正在載入上一頁內容 載入失敗,點擊重試 圖片 影片 登入 新增 搜尋 登入成功 登入失敗 選擇 登出 設定 捐贈 Feeds 儲存 完成 Bluesky Bluesky 是一個開放網路。透過一個帳號,你既可以使用易上手的社群網路,也可以在整個社交互聯網上擁有共享身分。 長毛象 Mastodon 是一個去中心化、非商業化的社群媒體,用戶可以掌控自己的時間軸,每個伺服器都是獨立的實體。 混合內容 混合內容是由 Fread 提供的自訂聚合訊息流,你可以新增來自長毛象、Bluesky、RSS 等平台的訊息來源,這些來源的內容會依時間倒序形成一個聚合訊息流。 長毛象 新增內容 新增成功,當前帳號: 新增成功,是否登入? 該內容已存在 內容新增成功 該名稱已存在 請輸入使用者名稱 名稱 簡介 請輸入使用者名稱 請輸入簡介 確定要退出編輯嗎? 新增成功 請輸入要登入的伺服器 請選擇要登入的伺服器 請輸入伺服器位址 請輸入伺服器位址 伺服器位址 請登入 Fread 支援新增多個帳號,新增後的帳號可以用來發文、按讚、轉發、留言等操作,還可以查看已新增帳號建立的清單。 暫無內容 公開 公開,但不會出現在探索功能 僅追隨者可見 僅被提及用戶可見 警告文字 發送成功 發送失敗:%1$s 退出後已編輯的內容不會被儲存,確定要退出嗎? 請輸入內容 部分帳號發佈失敗,已從佇列移除成功的帳號,請重新嘗試:%1$s 請選擇帳號 暫無其他帳號 在 %1$s 內搜尋 搜尋失敗 未搜尋到結果 搜尋 搜尋 %1$s 用戶 貼文 標籤 伺服器 探索 貼文 用戶 話題 新增服務 確定新增 搜尋 未搜尋到任何結果 匯入 請選擇要匯入的 OPML 檔案 匯入 新增內容 內容名稱 請輸入內容名稱 請新增訂閱源 未搜尋到任何結果 實例名稱、實例位址,用戶位址、RSS Fread 支援新增[[長毛象]]、[[Bluesky]]以及[[ RSS ]]內容,甚至是[[混合內容]]。 請直接在此處輸入長毛象或 Bluesky [[用戶名稱]]、[[WebFinger\Handle]],用戶[[首頁位址]]或 [[RSS]] 位址。 請先新增內容 需要先新增內容的訂閱源哦,Fread 支援多種內容類型,以及不同平台的混合內容,可以打造您自己的訊息流。 新增內容 請選擇內容類型 新增成功,是否登入? 請輸入一個名稱 請新增訊息源 請選擇要登入的伺服器 搜尋 長毛象或 Bluesky 用戶名、WebFinger、URL、RSS 請選擇一個帳號 請輸入內容名稱 名稱 您確定要刪除這個內容嗎? 狀態異常,未找到設定 確定要退出嗎? 確定要刪除嗎? 請選擇內容類型 請選擇您要新增的內容類型 通知 當前沒有已登入帳號 所有 提及 帳號 關於 請我喝杯咖啡以支持這個專案 Inline 影片自動播放 開啟後,Feeds 流中的影片將會自動播放。 敏感內容預設顯示 開啟後,敏感內容會預設顯示(僅限 Mastodon 平台)。 深色模式 深色模式 淺色模式 跟隨系統 開源聲明 本專案使用到的依賴庫之開源聲明 給我們回饋 加入 Telegram 群組或其他方式 加入 Telegram 群組 建立 GitHub issue 發送郵件 顯示語言 簡體中文 English 跟隨系統 給我們評價 如果覺得不錯請到應用商店給我們好評 內容字體大小 調整內容的字體和圖示大小 沉浸式導覽列 開啟後,首頁滑動時會隱藏底部的導覽列。 首頁的時間軸預設位置 上次閱讀位置 最新位置 主題色 預設 系統 版本: 官網: 開發者: 聯繫我: Telegram 群組: 隱私政策: 檢查更新 當前版本已經是最新 有新版本,點擊更新。 請選擇捐贈方式 登入已失效 訊息源屬性 自訂標題 訂閱位址 主頁 新增日期 上次更新時間 Bluesky 登入 服務商位址 使用者名稱或信箱 密碼 驗證碼 編輯 瀏覽更多 Feeds 建立者 %1$s %1$s 位用戶喜歡 作者 建立者 %1$s · %2$s 位用戶喜歡 追蹤中 貼文 回覆 媒體 喜歡 已封鎖用戶 已隱藏用戶 隱藏 %1$s 取消隱藏 封鎖 %1$s 取消封鎖 您確認要封鎖對方嗎? 您確認要隱藏對方嗎? 編輯個人資料 描述文字 請輸入描述文字 新貼文 請輸入您的貼文 描述文字 選項 %1$s 投票時長 功能 單選 多選 請選擇功能 請輸入投票內容 媒體暫未上傳 此功能需登入後才能使用哦 登入狀態失效,請重新登入。 內容清單 混合內容 · 條訂閱源 設定 捐贈 通知權限 拒絕 允許 為了接收互動訊息,請允許通知權限。 Amoled 模式 啟用此功能後,深色模式下會將背景切換為純黑,以降低耗電並在暗色環境中提供更高的對比度。 外觀 主題、顯示與首頁樣式設定 行為 內容顯示與互動行為設定 首頁頂部的切換內容按鈕顯示 首頁右上角是否顯示用於切換到下一個內容的按鈕 首頁頂部的重新整理按鈕顯示 首頁右上角是否顯示用於重新整理內容的按鈕 透過系統瀏覽器開啟連結 啟用後,Fread 會透過系統瀏覽器開啟連結。 頂部和底部列純色背景 將頂部和底部列改為純色,而不是高斯模糊。 ================================================ FILE: localization/src/commonMain/composeResources/values-zh-rTW/strings_mastodon.xml ================================================ 加入時間 %1$s 加入 封鎖 %1$s 封鎖 %1$s 取消封鎖 %1$s 編輯備註 請輸入備註 您確認要封鎖對方嗎? 您確認要封鎖對方實例嗎? 已封鎖用戶 已隱藏用戶 隱藏 %1$s 取消隱藏 %1$s 隱藏用戶? 他們不會知道自己被隱藏 他們仍可看到你的貼文,但你不會看到他們的貼文。 你將不會看到提及他們的貼文。 他們可以提及並追蹤你,但你不會看到他們。 隱藏 新增實例 請輸入伺服器位址 請輸入正確的伺服器位址 首頁 本站 跨站 趨勢 %1$s 則貼文 · %2$s 人參與 · 今日 %3$s 則貼文 您確定要取消追蹤這個話題嗎? 關於 找不到設定 暫無用戶 暫無已隱藏的用戶 取消隱藏 暫無已封鎖的用戶 取消封鎖 收藏的內容 按讚的內容 追蹤的話題 過濾器 生效中 已失效 編輯過濾器 標題 請輸入標題 持續時間 永久 三十分鐘 一小時 十二小時 一天 三天 一週 自訂 結束於 %1$s 已隱藏關鍵字 編輯關鍵字 關鍵字 確定刪除關鍵字嗎? 已隱藏關鍵字 %1$s 個已隱藏的關鍵字 隱藏來源 請選擇隱藏來源 首頁 & 清單 通知 公共時間軸 嘟文 & 回覆 個人資料 包含內容警告的內容 仍會顯示符合此過濾器的嘟文,但會加上內容警告 確定要刪除嗎? 確定要退出嗎? 整個詞條 嘟文 用戶 話題 建立的清單 建立清單 清單名稱 回覆顯示範圍 不顯示 清單成員 我追蹤的所有人 清單成員 在首頁時間軸中隱藏成員 清單成員的嘟文不會顯示在你的首頁時間軸,避免重複閱讀。 確定移除該用戶? 請輸入清單名稱 目前仍有變更未儲存,確認要退出嗎? 請輸入搜尋內容 確定要刪除這個清單嗎? 認證狀態異常 首頁 本站 跨站 語言:%1$s 月活躍:%1$s 貼文 回覆 媒體 關於 關於 站點規則 話題 最近兩天有 %1$s 人在討論 熱門 選擇一個實例 請輸入實例名稱或實例 URL ================================================ FILE: localization/src/commonMain/composeResources/values-zh-rTW/strings_status_ui.xml ================================================ 已轉發 轉發 引用 喜歡 評論 刪除 分享 收藏 取消收藏 回覆 追蹤 取消追蹤 置頂 取消置頂 編輯 內容已被「%1$s」過濾 新內容 確定要刪除這則內容嗎? 敏感內容 顯示更多 隱藏內容 投票 %1$s 票 · 已關閉 %1$s 票 · 已關閉 在瀏覽器中開啟 開啟對方實例 複製連結 翻譯 使用其他帳號開啟 僅被提及用戶可見 置頂 已編輯 %1$s 次喜歡 %1$s 次轉發 編輯於 %1$s 翻譯中… 顯示原文 確定刪除這個內容? 修改內容名稱 編輯內容名稱 請輸入內容名稱 顯示在首頁的清單 可長按拖曳排序 可新增到首頁的清單 互相關注 已封鎖 已追蹤 追蹤 請求中 回追 對方正在請求追蹤你 您確認要取消追蹤對方嗎? 您確定要撤回追蹤請求嗎? 您確定要取消封鎖對方嗎? 追蹤了你 貼文 追蹤者 正在追蹤 展開對話 更新 %1$s 版本更新日誌:\n%2$s 選擇帳號 喜歡 收藏 修改個人資料 登出 你確定要登出嗎? 請輸入你要搜尋的貼文 未知通知 喜歡了你的動態 轉發了你的動態 查看投票結果 %1$s 票 · 已結束 追蹤了你 編輯了一則貼文 你收到一則追蹤請求 發佈了新貼文 和你中斷了關係 引用了你的貼文 回覆了你的貼文 正在追蹤的用戶 追蹤你的用戶 已隱藏用戶 已封鎖用戶 按讚的用戶 轉發的用戶 隱藏 封鎖 已隱藏 已封鎖 您確定要隱藏該用戶嗎? 您確定要封鎖該用戶嗎? 發文 寫下你的想法 回覆給 [[%1$s]] 任何人都可以互動 已限制互動 貼文互動選項 自訂哪些人可以互動這則貼文。 引用選項 允許引用貼文 回覆選項 允許這些人回覆: 所有人 僅限自己 或組合選擇這些選項: 被提及的用戶 你追蹤的用戶 你的追蹤者 「%1$s」中的用戶 新增替代文字 補充描述替代文字 替代文字 登入資訊已失效,請重新登入 登入 請選擇帳號 選擇語言 詳情 替代文字 當前引用無法使用 取消轉發 任何人 僅限追蹤者 僅限自己 可見性 ================================================ FILE: localization/src/commonMain/kotlin/com/zhangke/fread/localization/LanguageCode.kt ================================================ package com.zhangke.fread.localization enum class LanguageCode(val code: String) { EN_US("en-US"), DE_DE("de-DE"), ES_ES("es-ES"), FR_FR("fr-FR"), JA_JP("ja-JP"), PT_PT("pt-PT"), RU_RU("ru-RU"), ZH_CN("zh-CN"), ZH_HK("zh-HK"), ZH_TW("zh-TW"); companion object { fun fromCode(code: String): LanguageCode? { return LanguageCode.entries.find { it.code == code } } } } ================================================ FILE: localization/src/commonMain/kotlin/com/zhangke/fread/localization/LanguageCodeNames.kt ================================================ package com.zhangke.fread.localization import androidx.compose.runtime.Composable val LanguageCode.displayName: String @Composable get() = when (this) { LanguageCode.ZH_CN -> "简体中文" LanguageCode.ZH_HK -> "繁體中文(香港)" LanguageCode.ZH_TW -> "繁體中文(台灣)" LanguageCode.EN_US -> "English" LanguageCode.DE_DE -> "Deutsch" LanguageCode.ES_ES -> "Español" LanguageCode.FR_FR -> "Français" LanguageCode.JA_JP -> "日本語" LanguageCode.PT_PT -> "Português" LanguageCode.RU_RU -> "Русский" } ================================================ FILE: localization/src/commonMain/kotlin/com/zhangke/fread/localization/Localization.kt ================================================ package com.zhangke.fread.localization object Localization { val supportedLanguage: List = LanguageCode.entries } ================================================ FILE: localization/src/commonMain/kotlin/com/zhangke/fread/localization/LocalizedString.kt ================================================ package com.zhangke.fread.localization val localizedString = Res.string /** * generated by LLM */ object LocalizedString { val skip = localizedString.skip val alert = localizedString.alert val durationMinute = localizedString.duration_minute val durationHour = localizedString.duration_hour val durationDay = localizedString.duration_day val durationWeek = localizedString.duration_week val cancel = localizedString.cancel val ok = localizedString.ok val empty = localizedString.empty val durationSelectorTitle = localizedString.duration_selector_title val retry = localizedString.retry val loadMoreError = localizedString.load_more_error val unknownError = localizedString.unknown_error val networkError = localizedString.network_error val resourceNotFound = localizedString.resource_not_found val imageSaving = localizedString.image_saving val imageSaveSuccess = localizedString.image_save_success val imageSaveFailed = localizedString.image_save_failed val permissionWriteExternalPermissionDenied = localizedString.permission_write_external_permission_denied val feedsLoadPreviousPageLabel = localizedString.feeds_load_previous_page_label val feedsLoadPreviousPageFailedLabel = localizedString.feeds_load_previous_page_failed_label val image = localizedString.image val video = localizedString.video val login = localizedString.login val add = localizedString.add val search = localizedString.search val authSuccess = localizedString.auth_success val authFailed = localizedString.auth_failed val select = localizedString.select val logout = localizedString.logout val settings = localizedString.settings val donate = localizedString.donate val feeds = localizedString.feeds val save = localizedString.save val done = localizedString.done val blueskyName = localizedString.bluesky_name val blueskyDescription = localizedString.bluesky_description val mastodonName = localizedString.mastodon_name val mastodonDescription = localizedString.mastodon_description val mixedContentName = localizedString.mixed_content_name val mixedContentDescription = localizedString.mixed_content_description val statusProviderTypeActivityPub = localizedString.status_provider_type_activity_pub val addContentTitle = localizedString.add_content_title val addContentSuccessWithAccount = localizedString.add_content_success_with_account val addContentSuccessWithLoginReminder = localizedString.add_content_success_with_login_reminder val contentExistTips = localizedString.content_exist_tips val contentAddSuccess = localizedString.content_add_success val addFeedsPageEmptyNameExist = localizedString.add_feeds_page_empty_name_exist val editProfileNameEmpty = localizedString.edit_profile_name_empty val editProfileLabelName = localizedString.edit_profile_label_name val editProfileLabelNote = localizedString.edit_profile_label_note val editProfileInputNameHint = localizedString.edit_profile_input_name_hint val editProfileInputNoteHint = localizedString.edit_profile_input_note_hint val editProfileEditConfirmMessage = localizedString.edit_profile_edit_confirm_message val addContentSuccessSnackbar = localizedString.add_content_success_snackbar val sharedSelectLanguageTitle = localizedString.shared_select_language_title val sharedStatusContextScreenTitle = localizedString.shared_status_context_screen_title val sharedAltLabel = localizedString.shared_alt_label val loginDialogInputHint = localizedString.login_dialog_input_hint val loginDialogTitle = localizedString.login_dialog_title val loginDialogInputTip = localizedString.login_dialog_input_tip val loginDialogInputTitle = localizedString.login_dialog_input_title val loginDialogInputLabel = localizedString.login_dialog_input_label val loginDialogTargetTitle = localizedString.login_dialog_target_title val profileDescription = localizedString.profile_description val listContentEmptyPlaceholder = localizedString.list_content_empty_placeholder val sharedNotificationUnknownDesc = localizedString.shared_notification_unknown_desc val sharedNotificationFavouritedDesc = localizedString.shared_notification_favourited_desc val sharedNotificationReblogDesc = localizedString.shared_notification_reblog_desc val sharedNotificationPollDesc = localizedString.shared_notification_poll_desc val sharedNotificationPollCount = localizedString.shared_notification_poll_count val sharedNotificationFollowDesc = localizedString.shared_notification_follow_desc val sharedNotificationUpdateDesc = localizedString.shared_notification_update_desc val sharedNotificationFollowRequest = localizedString.shared_notification_follow_request val sharedNotificationNewStatusDesc = localizedString.shared_notification_new_status_desc val sharedNotificationSeveredDesc = localizedString.shared_notification_severed_desc val sharedNotificationQuoteDesc = localizedString.shared_notification_quote_desc val sharedNotificationReplyDesc = localizedString.shared_notification_reply_desc val sharedUserListTitleFollowing = localizedString.shared_user_list_title_following val sharedUserListTitleFollowers = localizedString.shared_user_list_title_followers val sharedUserListTitleMutes = localizedString.shared_user_list_title_mutes val sharedUserListTitleBlocks = localizedString.shared_user_list_title_blocks val sharedUserListTitleLikes = localizedString.shared_user_list_title_likes val sharedUserListTitleReblog = localizedString.shared_user_list_title_reblog val sharedUserListActionMute = localizedString.shared_user_list_action_mute val sharedUserListActionBlock = localizedString.shared_user_list_action_block val sharedUserListActionMuted = localizedString.shared_user_list_action_muted val sharedUserListActionBlocked = localizedString.shared_user_list_action_blocked val sharedUserListActionMuteDialogMessage = localizedString.shared_user_list_action_mute_dialog_message val sharedUserListActionBlockDialogMessage = localizedString.shared_user_list_action_block_dialog_message val sharedPublishBlogTitle = localizedString.shared_publish_blog_title val sharedPublishBlogTextHint = localizedString.shared_publish_blog_text_hint val sharedPublishReplyInputHint = localizedString.shared_publish_reply_input_hint val sharedPublishInteractionNoLimit = localizedString.shared_publish_interaction_no_limit val sharedPublishInteractionLimited = localizedString.shared_publish_interaction_limited val sharedPublishInteractionDialogTitle = localizedString.shared_publish_interaction_dialog_title val sharedPublishInteractionDialogSubtitle = localizedString.shared_publish_interaction_dialog_subtitle val sharedPublishInteractionDialogQuoteTitle = localizedString.shared_publish_interaction_dialog_quote_title val sharedPublishInteractionDialogQuoteAllow = localizedString.shared_publish_interaction_dialog_quote_allow val sharedPublishInteractionDialogReplyTitle = localizedString.shared_publish_interaction_dialog_reply_title val sharedPublishInteractionDialogReplySubtitle = localizedString.shared_publish_interaction_dialog_reply_subtitle val sharedPublishInteractionDialogReplyAll = localizedString.shared_publish_interaction_dialog_reply_all val sharedPublishInteractionDialogReplyNobody = localizedString.shared_publish_interaction_dialog_reply_nobody val sharedPublishInteractionDialogReplyCombineTitle = localizedString.shared_publish_interaction_dialog_reply_combine_title val sharedPublishInteractionDialogMentioned = localizedString.shared_publish_interaction_dialog_mentioned val sharedPublishInteractionDialogFollowing = localizedString.shared_publish_interaction_dialog_following val sharedPublishInteractionDialogFollower = localizedString.shared_publish_interaction_dialog_follower val sharedPublishInteractionDialogInList = localizedString.shared_publish_interaction_dialog_in_list val sharedPublishMediaAltDialogTitle = localizedString.shared_publish_media_alt_dialog_title val sharedPublishMediaAltDialogInputTip = localizedString.shared_publish_media_alt_dialog_input_tip val sharedPublishMediaAltDialogInputHint = localizedString.shared_publish_media_alt_dialog_input_hint val sharedFeedsNotLoginTitle = localizedString.shared_feeds_not_login_title val sharedFeedsGoToLogin = localizedString.shared_feeds_go_to_login val sharedPublishSelectAccountTitle = localizedString.shared_publish_select_account_title val postStatusScopePublic = localizedString.post_status_scope_public val postStatusScopeUnlisted = localizedString.post_status_scope_unlisted val postStatusScopeFollowerOnly = localizedString.post_status_scope_follower_only val postStatusScopeMentionedOnly = localizedString.post_status_scope_mentioned_only val postStatusContentWarning = localizedString.post_status_content_warning val postStatusSuccess = localizedString.post_status_success val postStatusFailed = localizedString.post_status_failed val postStatusExitDialogContent = localizedString.post_status_exit_dialog_content val postStatusContentIsEmpty = localizedString.post_status_content_is_empty val postStatusPartFailed = localizedString.post_status_part_failed val selectAccountOpenStatusTitle = localizedString.select_account_open_status_title val selectAccountOpenStatusEmpty = localizedString.select_account_open_status_empty val selectAccountOpenStatusSearchIn = localizedString.select_account_open_status_search_in val selectAccountOpenStatusSearchFailed = localizedString.select_account_open_status_search_failed val status_ui_repost = localizedString.status_ui_repost val status_ui_reposted = localizedString.status_ui_reposted val statusUiQuote = localizedString.status_ui_quote val statusUiLike = localizedString.status_ui_like val statusUiComment = localizedString.status_ui_comment val statusUiDelete = localizedString.status_ui_delete val statusUiShare = localizedString.status_ui_share val statusUiBookmark = localizedString.status_ui_bookmark val statusUiUnbookmark = localizedString.status_ui_unbookmark val statusUiReply = localizedString.status_ui_reply val statusUiFollow = localizedString.status_ui_follow val statusUiUnfollow = localizedString.status_ui_unfollow val statusUiPin = localizedString.status_ui_pin val statusUiUnpin = localizedString.status_ui_unpin val statusUiEdit = localizedString.status_ui_edit val statusUiSensitiveByFilter = localizedString.status_ui_sensitive_by_filter val statusUiNewStatus = localizedString.status_ui_new_status val statusUiDeleteStatusConfirm = localizedString.status_ui_delete_status_confirm val statusUiImageSensitiveLabel = localizedString.status_ui_image_sensitive_label val statusUiImageContentShowHiddenLabel = localizedString.status_ui_image_content_show_hidden_label val statusUiImageContentHideHiddenLabel = localizedString.status_ui_image_content_hide_hidden_label val statusUiPollVote = localizedString.status_ui_poll_vote val statusUiPollVoteFinishedTip = localizedString.status_ui_poll_vote_finished_tip val statusUiPollVotesFinishedTip = localizedString.status_ui_poll_votes_finished_tip val statusUiInteractionOpenInBrowser = localizedString.status_ui_interaction_open_in_browser val statusUiInteractionOpenOriginalInstance = localizedString.status_ui_interaction_open_original_instance val statusUiInteractionCopyUrl = localizedString.status_ui_interaction_copy_url val statusUiInteractionTranslate = localizedString.status_ui_interaction_translate val statusUiInteractionOpenBlogByOtherAccount = localizedString.status_ui_interaction_open_blog_by_other_account val statusUiVisibilityMentionedOnly = localizedString.status_ui_visibility_mentioned_only val statusUiLabelPinned = localizedString.status_ui_label_pinned val statusUiInfoLabelEdited = localizedString.status_ui_info_label_edited val statusUiInteractionLabelFavouritedCount = localizedString.status_ui_interaction_label_favourited_count val statusUiInteractionLabelBoostedCount = localizedString.status_ui_interaction_label_boosted_count val statusUiBottomLabelEditedAt = localizedString.status_ui_bottom_label_edited_at val statusUiTranslating = localizedString.status_ui_translating val statusUiTranslateShowOriginal = localizedString.status_ui_translate_show_original val statusUiEditContentDeleteDialogContent = localizedString.status_ui_edit_content_delete_dialog_content val statusUiEditContentNameTitle = localizedString.status_ui_edit_content_name_title val statusUiEditContentNameLabel = localizedString.status_ui_edit_content_name_label val statusUiEditContentNameHint = localizedString.status_ui_edit_content_name_hint val statusUiEditContentConfigShowingListTitle = localizedString.status_ui_edit_content_config_showing_list_title val statusUiEditContentConfigShowingListDescription = localizedString.status_ui_edit_content_config_showing_list_description val statusUiEditContentConfigHiddenListTitle = localizedString.status_ui_edit_content_config_hidden_list_title val statusUiUserDetailRelationshipMutuals = localizedString.status_ui_user_detail_relationship_mutuals val statusUiUserDetailRelationshipBlocking = localizedString.status_ui_user_detail_relationship_blocking val statusUiUserDetailRelationshipFollowing = localizedString.status_ui_user_detail_relationship_following val statusUiUserDetailRelationshipNotFollow = localizedString.status_ui_user_detail_relationship_not_follow val statusUiUserDetailRelationshipRequested = localizedString.status_ui_user_detail_relationship_requested val statusUiUserDetailRelationshipFollowBack = localizedString.status_ui_user_detail_relationship_follow_back val statusUiUserDetailRequestByTip = localizedString.status_ui_user_detail_request_by_tip val statusUiRelationshipBtnDialogContentCancelFollow = localizedString.status_ui_relationship_btn_dialog_content_cancel_follow val statusUiRelationshipBtnDialogContentCancelFollowRequest = localizedString.status_ui_relationship_btn_dialog_content_cancel_follow_request val statusUiRelationshipBtnDialogContentCancelBlocking = localizedString.status_ui_relationship_btn_dialog_content_cancel_blocking val statusUiUserDetailFollowsYou = localizedString.status_ui_user_detail_follows_you val statusUiUserDetailPosts = localizedString.status_ui_user_detail_posts val statusUiUserDetailFollowerInfo = localizedString.status_ui_user_detail_follower_info val statusUiUserDetailFollowingInfo = localizedString.status_ui_user_detail_following_info val statusUiTopLabelContinuedThread = localizedString.status_ui_top_label_continued_thread val statusUiUpdateDialogTitle = localizedString.status_ui_update_dialog_title val statusUiUpdateDialogReleaseNote = localizedString.status_ui_update_dialog_release_note val statusUiSwitchAccountDialogTitle = localizedString.status_ui_switch_account_dialog_title val statusUiLikes = localizedString.status_ui_likes val statusUiBookmarks = localizedString.status_ui_bookmarks val statusUiEditProfile = localizedString.status_ui_edit_profile val statusUiLogout = localizedString.status_ui_logout val statusUiLogoutDialogContent = localizedString.status_ui_logout_dialog_content val statusUiSearchAccountStatusHint = localizedString.status_ui_search_account_status_hint val explorerSearchNoResults = localizedString.explorer_search_no_results val explorerSearchBarHint = localizedString.explorer_search_bar_hint val explorerSearchBarHintSpecializePlatform = localizedString.explorer_search_bar_hint_specialize_platform val explorerSearchTabTitleAuthor = localizedString.explorer_search_tab_title_author val explorerSearchTabTitleStatus = localizedString.explorer_search_tab_title_status val explorerSearchTabTitleHashtag = localizedString.explorer_search_tab_title_hashtag val explorerSearchTabTitleServer = localizedString.explorer_search_tab_title_server val explorerTabTitle = localizedString.explorer_tab_title val explorerTabStatusTitle = localizedString.explorer_tab_status_title val explorerTabUsersTitle = localizedString.explorer_tab_users_title val explorerTabHashtagTitle = localizedString.explorer_tab_hashtag_title val addProviderPageTitle = localizedString.add_provider_page_title val addProviderPageConfirmButton = localizedString.add_provider_page_confirm_button val searchPageTitle = localizedString.search_page_title val searchResultNotFound = localizedString.search_result_not_found val feedsImportPageTitle = localizedString.feeds_import_page_title val feedsImportPageHint = localizedString.feeds_import_page_hint val feedsImportButton = localizedString.feeds_import_button val addFeedsPageTitle = localizedString.add_feeds_page_title val addFeedsPageFeedsNameLabel = localizedString.add_feeds_page_feeds_name_label val addFeedsPageFeedsNameHint = localizedString.add_feeds_page_feeds_name_hint val addFeedsPageFeedsEmpty = localizedString.add_feeds_page_feeds_empty val preAddFeedsNoResult = localizedString.pre_add_feeds_no_result val preAddFeedsHint = localizedString.pre_add_feeds_hint val preAddFeedsInputLabel1 = localizedString.pre_add_feeds_input_label_1 val preAddFeedsInputLabel2 = localizedString.pre_add_feeds_input_label_2 val emptyContentHintTitle = localizedString.empty_content_hint_title val emptyContentHintDesc = localizedString.empty_content_hint_desc val feedsAddContent = localizedString.feeds_add_content val selectFeedsTypeScreenTitle = localizedString.select_feeds_type_screen_title val feedsPreAddLoginDialogContent = localizedString.feeds_pre_add_login_dialog_content val addFeedsPageEmptyNameTips = localizedString.add_feeds_page_empty_name_tips val addFeedsPageEmptySourceTips = localizedString.add_feeds_page_empty_source_tips val addFeedsChooseAuthDialogTitle = localizedString.add_feeds_choose_auth_dialog_title val searchFeedsTitle = localizedString.search_feeds_title val searchFeedsTitleHint = localizedString.search_feeds_title_hint val feedsSelectAccountForPostStatus = localizedString.feeds_select_account_for_post_status val feedsMixedConfigEditNewNameDialogTitle = localizedString.feeds_mixed_config_edit_new_name_dialog_title val feedsMixedConfigEditNewNameDialogLabel = localizedString.feeds_mixed_config_edit_new_name_dialog_label val feedsMixedConfigEditDeleteContentDialogMessage = localizedString.feeds_mixed_config_edit_delete_content_dialog_message val feedsMixedConfigNotFound = localizedString.feeds_mixed_config_not_found val feedsImportBackDialogMessage = localizedString.feeds_import_back_dialog_message val feedsDeleteConfirmContent = localizedString.feeds_delete_confirm_content val feedsSelectTypeScreenTitle = localizedString.feeds_select_type_screen_title val feedsSelectTypeScreenContent = localizedString.feeds_select_type_screen_content val notificationTabTitle = localizedString.notification_tab_title val notificationsAccountEmptyTip = localizedString.notifications_account_empty_tip val notificationsTabAll = localizedString.notifications_tab_all val notificationsTabMention = localizedString.notifications_tab_mention val profilePageTitle = localizedString.profile_page_title val profileSettingAboutTitle = localizedString.profile_setting_about_title val profileSettingDonateDesc = localizedString.profile_setting_donate_desc val profileSettingInlineVideoAutoPlay = localizedString.profile_setting_inline_video_auto_play val profileSettingInlineVideoAutoPlaySubtitle = localizedString.profile_setting_inline_video_auto_play_subtitle val profileSettingAlwaysShowSensitiveContent = localizedString.profile_setting_always_show_sensitive_content val profileSettingAlwaysShowSensitiveContentSubtitle = localizedString.profile_setting_always_show_sensitive_content_subtitle val profileSettingDarkModeTitle = localizedString.profile_setting_dark_mode_title val profileSettingDarkModeDark = localizedString.profile_setting_dark_mode_dark val profileSettingDarkModeLight = localizedString.profile_setting_dark_mode_light val profileSettingDarkModeFollowSystem = localizedString.profile_setting_dark_mode_follow_system val profileSettingOpenSourceTitle = localizedString.profile_setting_open_source_title val profileSettingOpenSourceDesc = localizedString.profile_setting_open_source_desc val profileSettingOpenSourceFeedback = localizedString.profile_setting_open_source_feedback val profileSettingOpenSourceFeedbackDesc = localizedString.profile_setting_open_source_feedback_desc val profileSettingOpenSourceFeedbackTelegram = localizedString.profile_setting_open_source_feedback_telegram val profileSettingOpenSourceFeedbackGithub = localizedString.profile_setting_open_source_feedback_github val profileSettingOpenSourceFeedbackEmail = localizedString.profile_setting_open_source_feedback_email val profileSettingLanguageTitle = localizedString.profile_setting_language_title val profileSettingLanguageZh = localizedString.profile_setting_language_zh val profileSettingLanguageEn = localizedString.profile_setting_language_en val profileSettingLanguageSystem = localizedString.profile_setting_language_system val profileSettingRatting = localizedString.profile_setting_ratting val profileSettingRattingDesc = localizedString.profile_setting_ratting_desc val profileSettingFontSize = localizedString.profile_setting_font_size val profileSettingFontSizeDesc = localizedString.profile_setting_font_size_desc val profileSettingFontSizeSmall = localizedString.profile_setting_font_size_small val profileSettingFontSizeMedium = localizedString.profile_setting_font_size_medium val profileSettingFontSizeLarge = localizedString.profile_setting_font_size_large val profileSettingImmersiveNavBar = localizedString.profile_setting_immersive_nav_bar val profileSettingImmersiveNavBarDesc = localizedString.profile_setting_immersive_nav_bar_desc val profileSettingTimelinePosition = localizedString.profile_setting_timeline_position val profileSettingTimelinePositionLastRead = localizedString.profile_setting_timeline_position_last_read val profileSettingTimelinePositionNewest = localizedString.profile_setting_timeline_position_newest val profileSettingThemeTitle = localizedString.profile_setting_theme_title val profileSettingThemeDefault = localizedString.profile_setting_theme_default val profileSettingThemeSystem = localizedString.profile_setting_theme_system val profileAboutVersion = localizedString.profile_about_version val profileAboutWebsite = localizedString.profile_about_website val profileAboutDeveloper = localizedString.profile_about_developer val profileAboutContractUs = localizedString.profile_about_contract_us val profileAboutTelegram = localizedString.profile_about_telegram val profileAboutPrivacyPolicy = localizedString.profile_about_privacy_policy val profileSettingCheckForUpdate = localizedString.profile_setting_check_for_update val profileSettingAlreadyLatestVersion = localizedString.profile_setting_already_latest_version val profileSettingHaveNewVersion = localizedString.profile_setting_have_new_version val profileDonatePageTitle = localizedString.profile_donate_page_title val profileAccountNotLogin = localizedString.profile_account_not_login val rss_source_detail_screen_title = localizedString.rss_source_detail_screen_title val rss_source_detail_screen_custom_title = localizedString.rss_source_detail_screen_custom_title val rss_source_detail_screen_url = localizedString.rss_source_detail_screen_url val rss_source_detail_screen_home_url = localizedString.rss_source_detail_screen_home_url val rss_source_detail_screen_add_date = localizedString.rss_source_detail_screen_add_date val rss_source_detail_screen_last_update_date = localizedString.rss_source_detail_screen_last_update_date val bluesky_protocol_name = localizedString.bluesky_protocol_name val bsky_add_content_title = localizedString.bsky_add_content_title val bsky_add_content_hosting_provider = localizedString.bsky_add_content_hosting_provider val bsky_add_content_user_name = localizedString.bsky_add_content_user_name val bsky_add_content_password = localizedString.bsky_add_content_password val bsky_add_content_factor_token = localizedString.bsky_add_content_factor_token val bsky_edit_content_title = localizedString.bsky_edit_content_title val bsky_feeds_explorer_more = localizedString.bsky_feeds_explorer_more val bsky_feeds_explorer_creator_label = localizedString.bsky_feeds_explorer_creator_label val bsky_feeds_explorer_liked_by = localizedString.bsky_feeds_explorer_liked_by val bsky_feeds_detail_creator_prefix = localizedString.bsky_feeds_detail_creator_prefix val bsky_feeds_item_subtitle = localizedString.bsky_feeds_item_subtitle val bsky_feeds_following_name = localizedString.bsky_feeds_following_name val bsky_feeds_user_posts = localizedString.bsky_feeds_user_posts val bsky_feeds_user_replies = localizedString.bsky_feeds_user_replies val bsky_feeds_user_medias = localizedString.bsky_feeds_user_medias val bsky_feeds_user_likes = localizedString.bsky_feeds_user_likes val bsky_user_detail_action_blocked_list = localizedString.bsky_user_detail_action_blocked_list val bsky_user_detail_action_muted_list = localizedString.bsky_user_detail_action_muted_list val bsky_user_detail_action_mute_user = localizedString.bsky_user_detail_action_mute_user val bsky_user_detail_action_unmute_user = localizedString.bsky_user_detail_action_unmute_user val bsky_user_detail_action_block_user = localizedString.bsky_user_detail_action_block_user val bsky_user_detail_action_unblock_user = localizedString.bsky_user_detail_action_unblock_user val bsky_user_detail_action_block_user_dialog_message = localizedString.bsky_user_detail_action_block_user_dialog_message val bsky_user_detail_action_mute_user_dialog_message = localizedString.bsky_user_detail_action_mute_user_dialog_message val bsky_edit_profile_title = localizedString.bsky_edit_profile_title val activity_pub_login_exception = localizedString.activity_pub_login_exception val activity_pub_home_timeline = localizedString.activity_pub_home_timeline val activity_pub_local_timeline = localizedString.activity_pub_local_timeline val activity_pub_public_timeline = localizedString.activity_pub_public_timeline val activity_pub_instance_detail_language_label = localizedString.activity_pub_instance_detail_language_label val activity_pub_instance_detail_active_month_label = localizedString.activity_pub_instance_detail_active_month_label val activity_pub_user_detail_join_date = localizedString.activity_pub_user_detail_join_date val activity_pub_user_detail_tab_post = localizedString.activity_pub_user_detail_tab_post val activity_pub_user_detail_tab_replies = localizedString.activity_pub_user_detail_tab_replies val activity_pub_user_detail_tab_media = localizedString.activity_pub_user_detail_tab_media val activity_pub_user_detail_tab_about = localizedString.activity_pub_user_detail_tab_about val activity_pub_user_detail_tab_about_joined = localizedString.activity_pub_user_detail_tab_about_joined val activity_pub_user_detail_menu_block = localizedString.activity_pub_user_detail_menu_block val activity_pub_user_detail_menu_block_domain = localizedString.activity_pub_user_detail_menu_block_domain val activity_pub_user_detail_menu_unblock_domain = localizedString.activity_pub_user_detail_menu_unblock_domain val activity_pub_user_detail_menu_edit_private_note = localizedString.activity_pub_user_detail_menu_edit_private_note val activity_pub_user_detail_menu_edit_private_note_dialog_hint = localizedString.activity_pub_user_detail_menu_edit_private_note_dialog_hint val activity_pub_user_detail_dialog_content_block = localizedString.activity_pub_user_detail_dialog_content_block val activity_pub_user_detail_dialog_content_block_domain = localizedString.activity_pub_user_detail_dialog_content_block_domain val activity_pub_user_menu_blocked_user_list = localizedString.activity_pub_user_menu_blocked_user_list val activity_pub_user_menu_muted_user_list = localizedString.activity_pub_user_menu_muted_user_list val activity_pub_user_detail_menu_mute_user = localizedString.activity_pub_user_detail_menu_mute_user val activity_pub_user_detail_menu_unmute_user = localizedString.activity_pub_user_detail_menu_unmute_user val activity_pub_mute_user_bottom_sheet_title = localizedString.activity_pub_mute_user_bottom_sheet_title val activity_pub_mute_user_bottom_sheet_role1 = localizedString.activity_pub_mute_user_bottom_sheet_role1 val activity_pub_mute_user_bottom_sheet_role2 = localizedString.activity_pub_mute_user_bottom_sheet_role2 val activity_pub_mute_user_bottom_sheet_role3 = localizedString.activity_pub_mute_user_bottom_sheet_role3 val activity_pub_mute_user_bottom_sheet_role4 = localizedString.activity_pub_mute_user_bottom_sheet_role4 val activity_pub_mute_user_bottom_sheet_btn_mute = localizedString.activity_pub_mute_user_bottom_sheet_btn_mute val activity_pub_about = localizedString.activity_pub_about val activity_pub_about_rule_title = localizedString.activity_pub_about_rule_title val activity_pub_trends_tag = localizedString.activity_pub_trends_tag val activity_pub_trends_tag_description = localizedString.activity_pub_trends_tag_description val activity_pub_trends_status = localizedString.activity_pub_trends_status val input_media_desc_page_title = localizedString.input_media_desc_page_title val input_media_desc_input_hint = localizedString.input_media_desc_input_hint val post_status_page_title = localizedString.post_status_page_title val post_screen_input_hint = localizedString.post_screen_input_hint val post_screen_media_descriptor_placeholder = localizedString.post_screen_media_descriptor_placeholder val post_status_poll_item_hint = localizedString.post_status_poll_item_hint val post_status_poll_duration = localizedString.post_status_poll_duration val post_status_poll_function_title = localizedString.post_status_poll_function_title val post_status_poll_single = localizedString.post_status_poll_single val post_status_poll_multiple = localizedString.post_status_poll_multiple val post_status_poll_style_select_dialog_title = localizedString.post_status_poll_style_select_dialog_title val post_status_poll_is_empty = localizedString.post_status_poll_is_empty val post_status_media_is_not_upload = localizedString.post_status_media_is_not_upload val add_instance_screen_title = localizedString.add_instance_screen_title val add_instance_input_service_hint = localizedString.add_instance_input_service_hint val add_instance_input_service_error = localizedString.add_instance_input_service_error val activity_pub_content_tab_home = localizedString.activity_pub_content_tab_home val activity_pub_content_tab_local_timeline = localizedString.activity_pub_content_tab_local_timeline val activity_pub_content_tab_public_timeline = localizedString.activity_pub_content_tab_public_timeline val activity_pub_content_tab_trending = localizedString.activity_pub_content_tab_trending val activity_pub_hashtag_timeline_description = localizedString.activity_pub_hashtag_timeline_description val activity_pub_hashtag_unfollow_dialog_message = localizedString.activity_pub_hashtag_unfollow_dialog_message val activity_pub_edit_account_info_label_about = localizedString.activity_pub_edit_account_info_label_about val activity_pub_edit_content_screen_config_not_found = localizedString.activity_pub_edit_content_screen_config_not_found val activity_pub_user_list_empty = localizedString.activity_pub_user_list_empty val activity_pub_muted_user_list_empty = localizedString.activity_pub_muted_user_list_empty val activity_pub_muted_user_list_unmute = localizedString.activity_pub_muted_user_list_unmute val activity_pub_blocked_user_list_empty = localizedString.activity_pub_blocked_user_list_empty val activity_pub_blocked_user_list_unblock = localizedString.activity_pub_blocked_user_list_unblock val activity_pub_bookmarks_list_title = localizedString.activity_pub_bookmarks_list_title val activity_pub_favourites_list_title = localizedString.activity_pub_favourites_list_title val activity_pub_followed_tags_screen_title = localizedString.activity_pub_followed_tags_screen_title val activity_pub_filters_list_page_title = localizedString.activity_pub_filters_list_page_title val activity_pub_filters_active = localizedString.activity_pub_filters_active val activity_pub_filters_expired = localizedString.activity_pub_filters_expired val activity_pub_select_platform_title = localizedString.activity_pub_select_platform_title val activity_pub_select_platform_text_hint = localizedString.activity_pub_select_platform_text_hint val activity_pub_filter_edit_title = localizedString.activity_pub_filter_edit_title val activity_pub_filter_edit_input_title_label = localizedString.activity_pub_filter_edit_input_title_label val activity_pub_filter_edit_input_title_hint = localizedString.activity_pub_filter_edit_input_title_hint val activity_pub_filter_edit_duration = localizedString.activity_pub_filter_edit_duration val activity_pub_filter_edit_duration_permanent = localizedString.activity_pub_filter_edit_duration_permanent val activity_pub_filter_edit_duration_thirty_minutes = localizedString.activity_pub_filter_edit_duration_thirty_minutes val activity_pub_filter_edit_duration_one_hour = localizedString.activity_pub_filter_edit_duration_one_hour val activity_pub_filter_edit_duration_twelve_hours = localizedString.activity_pub_filter_edit_duration_twelve_hours val activity_pub_filter_edit_duration_one_day = localizedString.activity_pub_filter_edit_duration_one_day val activity_pub_filter_edit_duration_three_day = localizedString.activity_pub_filter_edit_duration_three_day val activity_pub_filter_edit_duration_one_week = localizedString.activity_pub_filter_edit_duration_one_week val activity_pub_filter_edit_duration_custom = localizedString.activity_pub_filter_edit_duration_custom val activity_pub_filter_edit_duration_finish_subtitle = localizedString.activity_pub_filter_edit_duration_finish_subtitle val activity_pub_filter_edit_keyword_title = localizedString.activity_pub_filter_edit_keyword_title val activity_pub_filter_edit_keyword_dialog_title = localizedString.activity_pub_filter_edit_keyword_dialog_title val activity_pub_filter_edit_keyword_dialog_hint = localizedString.activity_pub_filter_edit_keyword_dialog_hint val activity_pub_filter_edit_keyword_remove_keyword_dialog_content = localizedString.activity_pub_filter_edit_keyword_remove_keyword_dialog_content val activity_pub_filter_edit_keyword_list_title = localizedString.activity_pub_filter_edit_keyword_list_title val activity_pub_filter_edit_keyword_list_desc = localizedString.activity_pub_filter_edit_keyword_list_desc val activity_pub_filter_edit_context_title = localizedString.activity_pub_filter_edit_context_title val activity_pub_filter_edit_context_selector_title = localizedString.activity_pub_filter_edit_context_selector_title val activity_pub_filter_edit_context_home = localizedString.activity_pub_filter_edit_context_home val activity_pub_filter_edit_context_notification = localizedString.activity_pub_filter_edit_context_notification val activity_pub_filter_edit_context_timeline = localizedString.activity_pub_filter_edit_context_timeline val activity_pub_filter_edit_context_thread = localizedString.activity_pub_filter_edit_context_thread val activity_pub_filter_edit_context_account = localizedString.activity_pub_filter_edit_context_account val activity_pub_filter_edit_empty_context = localizedString.activity_pub_filter_edit_empty_context val activity_pub_filter_edit_warning_title = localizedString.activity_pub_filter_edit_warning_title val activity_pub_filter_edit_warning_desc = localizedString.activity_pub_filter_edit_warning_desc val activity_pub_filter_edit_delete_content = localizedString.activity_pub_filter_edit_delete_content val activity_pub_filter_edit_back_dialog = localizedString.activity_pub_filter_edit_back_dialog val activity_pub_filter_edit_whole_word = localizedString.activity_pub_filter_edit_whole_word val activity_pub_explorer_tab_status_title = localizedString.activity_pub_explorer_tab_status_title val activity_pub_explorer_tab_users_title = localizedString.activity_pub_explorer_tab_users_title val activity_pub_explorer_tab_hashtag_title = localizedString.activity_pub_explorer_tab_hashtag_title val activity_pub_created_list_title = localizedString.activity_pub_created_list_title val activity_pub_add_list_title = localizedString.activity_pub_add_list_title val activity_pub_add_list_name = localizedString.activity_pub_add_list_name val activity_pub_add_list_replies = localizedString.activity_pub_add_list_replies val activity_pub_add_list_replies_non = localizedString.activity_pub_add_list_replies_non val activity_pub_add_list_replies_list = localizedString.activity_pub_add_list_replies_list val activity_pub_add_list_replies_followers = localizedString.activity_pub_add_list_replies_followers val activity_pub_add_list_accounts = localizedString.activity_pub_add_list_accounts val activity_pub_add_list_hide_in_timeline = localizedString.activity_pub_add_list_hide_in_timeline val activity_pub_add_list_hide_in_timeline_desc = localizedString.activity_pub_add_list_hide_in_timeline_desc val activity_pub_add_list_remove_user_message = localizedString.activity_pub_add_list_remove_user_message val activity_pub_add_list_name_is_empty = localizedString.activity_pub_add_list_name_is_empty val activity_pub_add_list_back_reminder = localizedString.activity_pub_add_list_back_reminder val activity_pub_search_user_placeholder = localizedString.activity_pub_search_user_placeholder val activity_pub_list_delete_confirm = localizedString.activity_pub_list_delete_confirm val mixed_content_subtitle_1 = localizedString.mixed_content_subtitle_1 val mixed_content_subtitle_2 = localizedString.mixed_content_subtitle_2 val date_time_ago = localizedString.date_time_ago val date_time_ago_prefix = localizedString.date_time_ago_prefix val date_time_ago_suffix = localizedString.date_time_ago_suffix val date_time_day = localizedString.date_time_day val date_time_hour = localizedString.date_time_hour val date_time_minute = localizedString.date_time_minute val date_time_second = localizedString.date_time_second val authentication_page_normal_title = localizedString.authentication_page_normal_title val authentication_page_failed_title = localizedString.authentication_page_failed_title val main_drawer_title = localizedString.main_drawer_title val main_drawer_mixed_item_subtitle_1 = localizedString.main_drawer_mixed_item_subtitle_1 val main_drawer_mixed_item_subtitle_2 = localizedString.main_drawer_mixed_item_subtitle_2 val main_drawer_settings = localizedString.main_drawer_settings val main_drawer_donate = localizedString.main_drawer_donate val main_request_notification_dialog_title = localizedString.main_request_notification_dialog_title val main_request_notification_dialog_reject_button = localizedString.main_request_notification_dialog_reject_button val main_request_notification_dialog_allow_button = localizedString.main_request_notification_dialog_allow_button val main_request_notification_dialog_content = localizedString.main_request_notification_dialog_content val status_ui_embed_quote_unavailable = localizedString.status_ui_embed_quote_unavailable val status_ui_action_unforward = localizedString.status_ui_action_unforward val status_ui_quote_approval_public = localizedString.status_ui_quote_approval_public val status_ui_quote_approval_follower = localizedString.status_ui_quote_approval_follower val status_ui_quote_approval_nobody = localizedString.status_ui_quote_approval_nobody val status_ui_visibility = localizedString.status_ui_visibility val setting_item_amoled_mode = localizedString.setting_item_amoled_mode val setting_item_amoled_mode_description = localizedString.setting_item_amoled_mode_description val setting_group_appearance = localizedString.setting_group_appearance val setting_group_appearance_subtitle = localizedString.setting_group_appearance_subtitle val setting_group_behavior = localizedString.setting_group_behavior val setting_group_behavior_subtitle = localizedString.setting_group_behavior_subtitle val setting_item_home_tab_next_title = localizedString.setting_item_home_tab_next_title val setting_item_home_tab_next_subtitle = localizedString.setting_item_home_tab_next_subtitle val setting_item_home_tab_refresh_title = localizedString.setting_item_home_tab_refresh_title val setting_item_home_tab_refresh_subtitle = localizedString.setting_item_home_tab_refresh_subtitle val setting_item_open_url_by_system_title = localizedString.setting_item_open_url_by_system_title val setting_item_open_url_by_system_subtitle = localizedString.setting_item_open_url_by_system_subtitle val setting_item_solid_bar_background_title = localizedString.setting_item_solid_bar_background_title val setting_item_solid_bar_background_subtitle = localizedString.setting_item_solid_bar_background_subtitle } ================================================ FILE: plugins/activitypub-app/.gitignore ================================================ /build ================================================ FILE: plugins/activitypub-app/build.gradle.kts ================================================ plugins { id("fread.project.feature.kmp") id("com.google.devtools.ksp") id("kotlin-parcelize") alias(libs.plugins.room) } android { namespace = "com.zhangke.fread.activitypub.app" sourceSets { getByName("main") { assets.srcDirs("src/androidMain/assets") res.srcDirs("src/androidMain/res") manifest.srcFile("src/androidMain/AndroidManifest.xml") } } } kotlin { sourceSets { commonMain { dependencies { implementation(project(path = ":framework")) implementation(project(path = ":commonbiz:common")) implementation(project(path = ":bizframework:status-provider")) implementation(project(path = ":commonbiz:status-ui")) implementation(project(path = ":commonbiz:sharedscreen")) implementation(project(path = ":commonbiz:analytics")) implementation(compose.components.resources) implementation(libs.arrow.core) implementation(libs.activity.pub.client) implementation(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) implementation(libs.haze) implementation(libs.haze.materials) implementation(libs.jetbrains.lifecycle.viewmodel) implementation(libs.androidx.constraintlayout.compose.kmp) implementation(libs.imageLoader) implementation(libs.androidx.room) implementation(libs.auto.service.annotations) implementation(libs.androidx.paging.common) implementation(libs.leftright) implementation(libs.krouter.runtime) } } commonTest { dependencies { implementation(kotlin("test")) } } androidMain { dependencies { implementation(libs.androidx.core.ktx) implementation(libs.bundles.androidx.activity) implementation(libs.androidx.browser) } } } configureCommonMainKsp() } dependencies { kspAll(libs.androidx.room.compiler) kspAll(libs.auto.service.ksp) kspAll(libs.krouter.collecting.compiler) } compose { resources { publicResClass = false packageOfResClass = "com.zhangke.fread.activitypub.app" generateResClass = always } } room { schemaDirectory("$projectDir/schemas") } ================================================ FILE: plugins/activitypub-app/consumer-rules.pro ================================================ ================================================ FILE: plugins/activitypub-app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle.kts. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: plugins/activitypub-app/src/androidMain/AndroidManifest.xml ================================================ ================================================ FILE: plugins/activitypub-app/src/androidMain/kotlin/com/zhangke/fread/activitypub/app/ActivityPubAndroidModule.kt ================================================ package com.zhangke.fread.activitypub.app import androidx.room.Room import com.zhangke.fread.activitypub.app.internal.db.ActivityPubDatabases import com.zhangke.fread.activitypub.app.internal.db.ActivityPubLoggedAccountDatabase import com.zhangke.fread.activitypub.app.internal.db.status.ActivityPubStatusDatabases import com.zhangke.fread.activitypub.app.internal.db.status.ActivityPubStatusReadStateDatabases import com.zhangke.fread.activitypub.app.internal.push.ActivityPubPushManager import com.zhangke.fread.activitypub.app.internal.push.PushInfoDatabase import com.zhangke.fread.activitypub.app.internal.push.PushInfoRepo import com.zhangke.fread.activitypub.app.internal.push.notification.PushNotificationManager import org.koin.android.ext.koin.androidContext import org.koin.core.module.Module import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf actual fun Module.createPlatformModule() { single { Room.databaseBuilder( androidContext(), ActivityPubDatabases::class.java, ActivityPubDatabases.DB_NAME, ).build() } single { Room.databaseBuilder( androidContext(), ActivityPubLoggedAccountDatabase::class.java, ActivityPubLoggedAccountDatabase.DB_NAME, ).build() } single { Room.databaseBuilder( androidContext(), ActivityPubStatusDatabases::class.java, ActivityPubStatusDatabases.DB_NAME, ).addMigrations(ActivityPubStatusDatabases.MIGRATION_1_2) .build() } single { Room.databaseBuilder( androidContext(), ActivityPubStatusReadStateDatabases::class.java, ActivityPubStatusReadStateDatabases.DB_NAME, ).build() } single { Room.databaseBuilder( androidContext(), PushInfoDatabase::class.java, PushInfoDatabase.DB_NAME, ).build() } singleOf(::PushInfoRepo) factoryOf(::PushNotificationManager) factoryOf(::ActivityPubPushManager) } ================================================ FILE: plugins/activitypub-app/src/androidMain/kotlin/com/zhangke/fread/activitypub/app/internal/auth/ActivityPubOAuthRedirectActivity.kt ================================================ package com.zhangke.fread.activitypub.app.internal.auth import android.os.Bundle import androidx.activity.ComponentActivity import androidx.lifecycle.lifecycleScope import com.zhangke.framework.toast.toast import com.zhangke.fread.common.browser.OAuthHandler import com.zhangke.fread.localization.LocalizedString import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.android.ext.android.inject /** * Created by ZhangKe on 2022/12/4. */ class ActivityPubOAuthRedirectActivity : ComponentActivity() { private val oauthHandler by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val code = intent.data?.getQueryParameter("code") lifecycleScope.launch { if (code.isNullOrEmpty()) { toast(org.jetbrains.compose.resources.getString(LocalizedString.activity_pub_login_exception)) delay(2000) } else { oauthHandler.onOauthSuccess(code) } finish() } } } ================================================ FILE: plugins/activitypub-app/src/androidMain/kotlin/com/zhangke/fread/activitypub/app/internal/push/ActivityPubPushManager.android.kt ================================================ package com.zhangke.fread.activitypub.app.internal.push import android.util.Log import com.zhangke.activitypub.entities.SubscribePushRequestEntity import com.zhangke.activitypub.entities.SubscriptionAlertsEntity import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.common.config.FreadConfigManager import com.zhangke.fread.common.push.IPushManager import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.krouter.KRouter import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi actual class ActivityPubPushManager ( private val freadConfigManager: FreadConfigManager, private val clientManager: ActivityPubClientManager, private val pushInfoRepo: PushInfoRepo, ) { private val pushManager: IPushManager? by lazy { KRouter.getServices().firstOrNull() } @OptIn(ExperimentalEncodingApi::class) actual suspend fun subscribe(locator: PlatformLocator, accountId: String) { Log.d("PushManager", "subscribe for ${locator.accountUri}, $accountId") val pushManager = pushManager ?: return val deviceId = freadConfigManager.getDeviceId() val encodedAccountId = Base64.UrlSafe.encode(accountId.encodeToByteArray()) val endpointUrl = pushManager.getEndpointUrl(encodedAccountId, deviceId) val keys = CryptoUtil.generate() val subscribeRequest = SubscribePushRequestEntity( subscription = SubscribePushRequestEntity.Subscription( endpoint = endpointUrl, keys = SubscribePushRequestEntity.Subscription.Keys( p256dh = keys.encodedPublicKey, auth = keys.authKey, ), ), data = SubscribePushRequestEntity.Data( alerts = SubscriptionAlertsEntity( mention = true, status = true, follow = true, reblog = true, followRequest = true, favourite = true, poll = true, update = true, ), policy = "all", ), ) Log.d("PushManager", "subscribe end point: $endpointUrl") Log.d("PushManager", "subscribe keys : $keys") clientManager.getClient(locator) .pushRepo .subscribePush(subscribeRequest) .onSuccess { Log.d("PushManager", "subscribe success: $it") registerRelay(pushManager, deviceId, accountId, encodedAccountId, keys) }.onFailure { Log.d("PushManager", "subscribe failed: $it") } } actual suspend fun unsubscribe(locator: PlatformLocator, accountId: String) { Log.d("PushManager", "unsubscribe for ${locator.accountUri}, $accountId") val pushManager = pushManager ?: return clientManager.getClient(locator) .pushRepo .removeSubscription() .onSuccess { Log.d("PushManager", "unsubscribe success.") }.onFailure { Log.d("PushManager", "unsubscribe failed: $it") } unregisterRelay(pushManager, freadConfigManager.getDeviceId(), accountId) } private suspend fun registerRelay( pushManager: IPushManager, deviceId: String, accountId: String, encodedAccountId: String, keys: CryptoKeys, ) { pushManager.registerToRelay(encodedAccountId, deviceId) .onSuccess { Log.d( "PushManager", "registerRelay success, account id is $accountId, encoded: $encodedAccountId" ) val pushInfo = PushInfo( accountId = accountId, publicKey = keys.publicKey, privateKey = keys.privateKey, authKey = keys.authKey, ) pushInfoRepo.insert(pushInfo) }.onFailure { Log.d("PushManager", "registerRelay failed: $it") } } @OptIn(ExperimentalEncodingApi::class) private suspend fun unregisterRelay( pushManager: IPushManager, deviceId: String, accountId: String, ) { Log.d("PushManager", "unregisterRelay: $accountId") val encodedAccountId = Base64.UrlSafe.encode(accountId.encodeToByteArray()) pushManager.unregisterToRelay(encodedAccountId, deviceId) .onSuccess { Log.d("PushManager", "unregisterRelay success") }.onFailure { Log.d("PushManager", "unregisterRelay failed: $it") } } } ================================================ FILE: plugins/activitypub-app/src/androidMain/kotlin/com/zhangke/fread/activitypub/app/internal/push/ActivityPubPushMessageReceiverHelper.android.kt ================================================ package com.zhangke.fread.activitypub.app.internal.push import android.util.Log import com.zhangke.framework.architect.json.fromJson import com.zhangke.framework.architect.json.getLongOrNull import com.zhangke.framework.architect.json.getStringOrNull import com.zhangke.framework.architect.json.globalJson import com.zhangke.framework.utils.appContext import com.zhangke.fread.activitypub.app.internal.push.notification.ActivityPubPushMessage import com.zhangke.fread.activitypub.app.internal.push.notification.PushNotificationManager import com.zhangke.fread.activitypub.app.internal.repo.account.ActivityPubLoggedAccountRepo import com.zhangke.fread.common.push.PushMessage import com.zhangke.fread.status.account.LoggedAccount import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonObject import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi actual class ActivityPubPushMessageReceiverHelper : KoinComponent { private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val pushNotificationManager: PushNotificationManager by inject() private val accountRepo: ActivityPubLoggedAccountRepo by inject() private val pushInfoRepo: PushInfoRepo by inject() @OptIn(ExperimentalEncodingApi::class) actual fun onReceiveNewMessage(message: PushMessage) { val accountId = String(Base64.UrlSafe.decode(message.encodedAccountId)) coroutineScope.launch { val info = pushInfoRepo.getPushInfo(accountId) ?: return@launch val account = accountRepo.queryAll().firstOrNull { it.userId == accountId } ?: return@launch val pushMessage = try { CryptoUtil.decryptData( keys = info, serverPublicKeyEncoded = message.cryptoKey, data = Base64.decode(message.messageData), encryptionSalt = message.encryption, contentEncoding = message.contentEncoding, ).let { convertDataToNotification(it, account) } } catch (e: Throwable) { Log.d("PushManager", "decrypted data error: ${e.stackTraceToString()}") null } Log.d("PushManager", "pushMessage: $pushMessage") if (pushMessage != null) { pushNotificationManager.onReceiveNewMessage(appContext, pushMessage) } } } private fun convertDataToNotification( data: String, account: LoggedAccount, ): ActivityPubPushMessage? { val jsonObject = globalJson.fromJson(data) return ActivityPubPushMessage( accessToken = jsonObject.getStringOrNull("access_token"), preferredLocale = jsonObject.getStringOrNull("preferred_locale"), notificationId = jsonObject.getLongOrNull("notification_id"), notificationType = jsonObject.getStringOrNull("notification_type") ?.let { ActivityPubPushMessage.Type.fromName(it) } ?: return null, icon = jsonObject.getStringOrNull("icon") ?: return null, title = jsonObject.getStringOrNull("title") ?: return null, body = jsonObject.getStringOrNull("body") ?: return null, account = account, ) } } ================================================ FILE: plugins/activitypub-app/src/androidMain/kotlin/com/zhangke/fread/activitypub/app/internal/push/CryptoUtil.kt ================================================ package com.zhangke.fread.activitypub.app.internal.push import android.util.Base64 import java.io.ByteArrayOutputStream import java.io.IOException import java.nio.charset.StandardCharsets import java.security.KeyFactory import java.security.KeyPairGenerator import java.security.PublicKey import java.security.SecureRandom import java.security.interfaces.ECPublicKey import java.security.spec.ECGenParameterSpec import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.X509EncodedKeySpec import java.util.Arrays import javax.crypto.Cipher import javax.crypto.KeyAgreement import javax.crypto.Mac import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec object CryptoUtil { private const val EC_CURVE_NAME = "prime256v1" private val P256_HEAD = byteArrayOf( 0x30.toByte(), 0x59.toByte(), 0x30.toByte(), 0x13.toByte(), 0x06.toByte(), 0x07.toByte(), 0x2a.toByte(), 0x86.toByte(), 0x48.toByte(), 0xce.toByte(), 0x3d.toByte(), 0x02.toByte(), 0x01.toByte(), 0x06.toByte(), 0x08.toByte(), 0x2a.toByte(), 0x86.toByte(), 0x48.toByte(), 0xce.toByte(), 0x3d.toByte(), 0x03.toByte(), 0x01.toByte(), 0x07.toByte(), 0x03.toByte(), 0x42.toByte(), 0x00.toByte() ) private val reBase64UrlSafe = """[_-]""".toRegex() fun generate(): CryptoKeys { val base64Flag = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING val generator = KeyPairGenerator.getInstance("EC") generator.initialize(ECGenParameterSpec(EC_CURVE_NAME)) val keyPair = generator.generateKeyPair() val privateKey = Base64.encodeToString(keyPair.private.encoded, base64Flag) val publicKey = Base64.encodeToString(keyPair.public.encoded, base64Flag) val authKey = ByteArray(16) val secureRandom = SecureRandom() secureRandom.nextBytes(authKey) val pushAuthKey = Base64.encodeToString(authKey, base64Flag) return CryptoKeys( privateKey = privateKey, publicKey = publicKey, encodedPublicKey = Base64.encodeToString( serializeRawPublicKey(keyPair.public), base64Flag, ), authKey = pushAuthKey, ) } private fun serializeRawPublicKey(key: PublicKey): ByteArray { val point = (key as ECPublicKey).w var x = point.affineX.toByteArray() var y = point.affineY.toByteArray() if (x.size > 32) x = Arrays.copyOfRange(x, x.size - 32, x.size) if (y.size > 32) y = Arrays.copyOfRange(y, y.size - 32, y.size) val result = ByteArray(65) result[0] = 4 System.arraycopy(x, 0, result, 1 + (32 - x.size), x.size) System.arraycopy(y, 0, result, result.size - y.size, y.size) return result } fun decryptData( keys: PushInfo, serverPublicKeyEncoded: String, encryptionSalt: String, contentEncoding: String, data: ByteArray, ): String { val base64Flag = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING val salt = encryptionSalt.decodeBase64() val serverKey = deserializeRawPublicKey(serverPublicKeyEncoded.decodeBase64())!! val keyFactory = KeyFactory.getInstance("EC") val formalPrivateKey = keyFactory.generatePrivate( PKCS8EncodedKeySpec(Base64.decode(keys.privateKey, base64Flag)) ) val publicKey = keyFactory.generatePublic( X509EncodedKeySpec(Base64.decode(keys.publicKey, base64Flag)) ) val keyAgreement = KeyAgreement.getInstance("ECDH") keyAgreement.init(formalPrivateKey) keyAgreement.doPhase(serverKey, true) val sharedSecret = keyAgreement.generateSecret() val secondSaltInfo = "Content-Encoding: auth\u0000".toByteArray(StandardCharsets.UTF_8) val authKey = Base64.decode(keys.authKey, base64Flag) val secondSalt: ByteArray = deriveKey(authKey, sharedSecret, secondSaltInfo, 32) val keyInfo: ByteArray = info(contentEncoding, publicKey, serverKey) val key = deriveKey(salt, secondSalt, keyInfo, 16) val nonceInfo: ByteArray = info("nonce", publicKey, serverKey) val nonce = deriveKey(salt, secondSalt, nonceInfo, 12) val cipher = Cipher.getInstance("AES/GCM/NoPadding") val aesKey = SecretKeySpec(key, "AES") val iv = GCMParameterSpec(128, nonce) cipher.init(Cipher.DECRYPT_MODE, aesKey, iv) val decrypted = cipher.doFinal(data) return String(decrypted, 2, decrypted.size - 2, StandardCharsets.UTF_8) } private fun String.decodeBase64(): ByteArray { return Base64.decode( this, if (reBase64UrlSafe.containsMatchIn(this)) Base64.URL_SAFE else Base64.DEFAULT, ) } private fun deriveKey( firstSalt: ByteArray, secondSalt: ByteArray, info: ByteArray, length: Int ): ByteArray { val hmacContext = Mac.getInstance("HmacSHA256") hmacContext.init(SecretKeySpec(firstSalt, "HmacSHA256")) val hmac = hmacContext.doFinal(secondSalt) hmacContext.init(SecretKeySpec(hmac, "HmacSHA256")) hmacContext.update(info) val result = hmacContext.doFinal(byteArrayOf(1)) return if (result.size <= length) result else Arrays.copyOfRange(result, 0, length) } private fun deserializeRawPublicKey(rawBytes: ByteArray): PublicKey? { if (rawBytes.size != 65 && rawBytes.size != 64) return null val kf = KeyFactory.getInstance("EC") val os = ByteArrayOutputStream() os.write(P256_HEAD) if (rawBytes.size == 64) os.write(4) os.write(rawBytes) return kf.generatePublic(X509EncodedKeySpec(os.toByteArray())) } private fun info( type: String, clientPublicKey: PublicKey, serverPublicKey: PublicKey ): ByteArray { val info = ByteArrayOutputStream() try { info.write("Content-Encoding: ".toByteArray(StandardCharsets.UTF_8)) info.write(type.toByteArray(StandardCharsets.UTF_8)) info.write(0) info.write("P-256".toByteArray(StandardCharsets.UTF_8)) info.write(0) info.write(0) info.write(65) info.write(serializeRawPublicKey(clientPublicKey)) info.write(0) info.write(65) info.write(serializeRawPublicKey(serverPublicKey)) } catch (ignore: IOException) { } return info.toByteArray() } } data class CryptoKeys( val privateKey: String, val encodedPublicKey: String, val publicKey: String, val authKey: String, ) ================================================ FILE: plugins/activitypub-app/src/androidMain/kotlin/com/zhangke/fread/activitypub/app/internal/push/PushInfoRepo.kt ================================================ package com.zhangke.fread.activitypub.app.internal.push import androidx.room.Dao import androidx.room.Database import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.RoomDatabase private const val DB_VERSION = 1 private const val TABLE_NAME = "PushInfo" @Entity(tableName = TABLE_NAME) data class PushInfo( @PrimaryKey val accountId: String, val publicKey: String, val privateKey: String, val authKey: String, ) @Dao interface PushInfoDao { @Query("SELECT * FROM $TABLE_NAME WHERE accountId = :accountId") suspend fun getPushInfo(accountId: String): PushInfo? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(info: PushInfo) @Query("DELETE FROM $TABLE_NAME WHERE accountId = :accountId") suspend fun delete(accountId: String) } @Database( entities = [PushInfo::class], version = DB_VERSION, exportSchema = false, ) abstract class PushInfoDatabase : RoomDatabase() { abstract fun pushInfoDao(): PushInfoDao companion object { const val DB_NAME = "ActivityPubPushInfo.db" } } class PushInfoRepo(database: PushInfoDatabase) { private val pushDao = database.pushInfoDao() suspend fun getPushInfo(accountId: String): PushInfo? { return pushDao.getPushInfo(accountId) } suspend fun insert(info: PushInfo) { pushDao.insert(info) } suspend fun delete(accountId: String) { pushDao.delete(accountId) } } ================================================ FILE: plugins/activitypub-app/src/androidMain/kotlin/com/zhangke/fread/activitypub/app/internal/push/notification/ActivityPubPushMessage.kt ================================================ package com.zhangke.fread.activitypub.app.internal.push.notification import com.zhangke.fread.status.account.LoggedAccount data class ActivityPubPushMessage( val accessToken: String?, val preferredLocale: String?, val notificationId: Long?, val account: LoggedAccount?, val notificationType: Type, val icon: String, val title: String, val body: String, ) { enum class Type { FAVORITE, MENTION, REBLOG, FOLLOW, POLL; companion object { fun fromName(name: String): Type? { return when (name) { "favourite" -> FAVORITE "mention" -> MENTION "reblog" -> REBLOG "follow" -> FOLLOW "poll" -> POLL else -> null } } } } } ================================================ FILE: plugins/activitypub-app/src/androidMain/kotlin/com/zhangke/fread/activitypub/app/internal/push/notification/PushNotificationManager.kt ================================================ package com.zhangke.fread.activitypub.app.internal.push.notification import android.Manifest import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri import com.seiko.imageloader.imageLoader import com.seiko.imageloader.model.ImageRequest import com.seiko.imageloader.option.SizeResolver import com.zhangke.framework.imageloader.executeSafety import com.zhangke.framework.utils.asBitmapOrNull import com.zhangke.framework.utils.maybe import com.zhangke.fread.activitypub.app.internal.repo.account.ActivityPubLoggedAccountRepo import com.zhangke.fread.common.action.OpenNotificationPageAction import com.zhangke.fread.commonbiz.R import com.zhangke.fread.commonbiz.ic_fread_logo import kotlin.random.Random class PushNotificationManager ( private val accountRepo: ActivityPubLoggedAccountRepo ) { companion object { private const val NOTIFICATION_CHANNEL_ID = "InteractionMessages" private const val NOTIFICATION_GROUP_KEY = "fread.xyz.interaction" } suspend fun onReceiveNewMessage(context: Context, message: ActivityPubPushMessage) { if (!checkSelfPushPermission(context)) return createNotificationChannel(context) val bitmap = downloadIcon(context, message.icon) val loggedAccountCount = accountRepo.queryAll().size val notificationIconColor = context.getColor(R.color.color_logo_background) val builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.ic_logo_skeleton) .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.ic_fread_logo)) .setLights(notificationIconColor, 500, 1000) .setColor(notificationIconColor) .maybe(bitmap != null) { it.setLargeIcon(bitmap) } .setContentTitle(message.title) .setContentText(message.body) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setGroup(NOTIFICATION_GROUP_KEY) .setContentIntent(buildNotificationIntent(context, message)) .maybe(loggedAccountCount > 1) { it.setSubText(message.account?.userName) } .setShowWhen(true) .setAutoCancel(true) .setCategory(Notification.CATEGORY_SOCIAL) val notificationId = Random.nextInt(0, Int.MAX_VALUE) (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) NotificationManagerCompat.from(context).notify(notificationId, builder.build()) } private suspend fun downloadIcon(context: Context, iconUrl: String): Bitmap? { val request = ImageRequest(iconUrl) { size(SizeResolver(100, 100)) options { isBitmap = true } } return context.imageLoader.executeSafety(request).asBitmapOrNull() } private fun buildNotificationIntent( context: Context, message: ActivityPubPushMessage ): PendingIntent { val openNavigationUri = OpenNotificationPageAction.buildOpenNotificationPageRoute() val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)!! intent.data = openNavigationUri.toUri() intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) } private fun checkSelfPushPermission(context: Context): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED } else { true } } private fun createNotificationChannel(context: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val channel = NotificationChannel( NOTIFICATION_CHANNEL_ID, "Interaction messages", NotificationManager.IMPORTANCE_DEFAULT, ).apply { description = "Notification for interaction messages" } context.pusManager.createNotificationChannel(channel) } private val Context.pusManager: NotificationManager get() = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } ================================================ FILE: plugins/activitypub-app/src/androidUnitTest/kotlin/com/zhangke/utopia/activitypubapp/ExampleUnitTest.kt ================================================ package com.zhangke.fread.activitypubapp import org.junit.Test import org.junit.Assert.* /** * Example local unit test, which will execute on the development machine (host). * * See [testing documentation](http://d.android.com/tools/testing). */ class ExampleUnitTest { @Test fun addition_isCorrect() { for (i in 10 downTo 0){ println(i) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/composeResources/drawable/detail_page_banner_background.xml ================================================ ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/ActivityPubAccountManager.kt ================================================ package com.zhangke.fread.activitypub.app import com.zhangke.framework.utils.exceptionOrThrow import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubAccountEntityAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubLoggedAccountAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubOAuthor import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.push.ActivityPubPushManager import com.zhangke.fread.activitypub.app.internal.repo.account.ActivityPubLoggedAccountRepo import com.zhangke.fread.activitypub.app.internal.uri.UserUriTransformer import com.zhangke.fread.activitypub.app.internal.usecase.ActivityPubAccountLogoutUseCase import com.zhangke.fread.common.di.ApplicationCoroutineScope import com.zhangke.fread.status.account.AccountRefreshResult import com.zhangke.fread.status.account.IAccountManager import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.FreadContent import com.zhangke.fread.status.model.LoggedAccountDetail import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.Relationships import com.zhangke.fread.status.model.notActivityPub import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.uri.FormalUri import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch class ActivityPubAccountManager( private val oAuthor: ActivityPubOAuthor, private val clientManager: ActivityPubClientManager, private val loggedAccountProvider: LoggedAccountProvider, private val accountRepo: ActivityPubLoggedAccountRepo, private val userUriTransformer: UserUriTransformer, private val accountAdapter: ActivityPubLoggedAccountAdapter, private val activityPubPushManager: ActivityPubPushManager, private val applicationCoroutineScope: ApplicationCoroutineScope, private val accountEntityAdapter: ActivityPubAccountEntityAdapter, private val accountLogout: ActivityPubAccountLogoutUseCase, ) : IAccountManager { init { applicationCoroutineScope.launch { accountRepo.getAllAccountFlow() .collect { clientManager.clearCache() loggedAccountProvider.updateAccounts(it) } } } override suspend fun getAllLoggedAccount(): List { return accountRepo.queryAll() } override fun getAllAccountFlow(): Flow> { return accountRepo.getAllAccountFlow() } override fun getAllAccountDetailFlow(): Flow>? { return accountRepo.getAllAccountFlow() .map { list -> list.map { accountEntityAdapter.convertLoggedAccountDetail(it) } } } fun observeAccount(accountUri: FormalUri): Flow { return accountRepo.observeAccount(accountUri.toString()) } override suspend fun refreshAllAccountInfo(): List { return accountRepo.queryAll().map { loggedAccount -> val locator = PlatformLocator(accountUri = loggedAccount.uri, baseUrl = loggedAccount.baseUrl) val client = clientManager.getClient(locator) val result = client.accountRepo .getAccount(loggedAccount.userId) .mapCatching { val newAccount = accountAdapter.createFromAccount( platform = loggedAccount.platform, account = it, token = loggedAccount.token, ) accountRepo.update(newAccount) newAccount } if (result.isFailure) { AccountRefreshResult.Failure(loggedAccount, result.exceptionOrThrow()) } else { AccountRefreshResult.Success(result.getOrThrow()) } } } override suspend fun triggerLaunchAuth(platform: BlogPlatform, account: LoggedAccount?) { if (platform.protocol.notActivityPub) return oAuthor.startOauth(platform.baseUrl) } override suspend fun logout(account: LoggedAccount): Boolean { if (account !is ActivityPubLoggedAccount) { return false } accountLogout(account) return true } override fun subscribeNotification() { applicationCoroutineScope.launch { accountRepo.queryAll().forEach { account -> subscribeNotificationForAccount(account) } accountRepo.onNewAccountFlow.collect { subscribeNotificationForAccount(it) } } } override suspend fun cancelFollowRequest( account: LoggedAccount, user: BlogAuthor, ): Result? { if (account.platform.protocol.notActivityPub) return null if (user.userId.isNullOrEmpty()) { return Result.failure(IllegalArgumentException("User ID cannot be null or empty")) } val locator = PlatformLocator(baseUrl = account.platform.baseUrl, accountUri = account.uri) return clientManager.getClient(locator) .accountRepo .unfollow(user.userId!!) .map { } } private suspend fun subscribeNotificationForAccount(account: ActivityPubLoggedAccount) { val role = PlatformLocator(baseUrl = account.platform.baseUrl, accountUri = account.uri) activityPubPushManager.subscribe(role, account.userId) } override suspend fun getRelationships( account: LoggedAccount, accounts: List, ): Result> { val userIdList = accounts.filter { userUriTransformer.parse(it.uri) != null } .mapNotNull { it.userId } if (userIdList.isEmpty()) return Result.success(emptyMap()) val locator = PlatformLocator( baseUrl = account.platform.baseUrl, accountUri = account.uri, ) return clientManager.getClient(locator) .accountRepo .getRelationships(idList = userIdList) .map { list -> val relationships = mutableMapOf() for (entity in list) { val user = accounts.firstOrNull { it.userId == entity.id } if (user != null) { relationships[user.uri] = accountEntityAdapter.convertRelationship(entity) } } relationships } } override suspend fun unblockAccount( account: LoggedAccount, user: BlogAuthor ): Result? { if (account.platform.protocol.notActivityPub) return null if (user.userId.isNullOrEmpty()) { return Result.failure(IllegalArgumentException("User ID cannot be null or empty")) } val locator = PlatformLocator( baseUrl = account.platform.baseUrl, accountUri = account.uri, ) return clientManager.getClient(locator) .accountRepo .unblock(user.userId!!) .map { } } override suspend fun selectContentWithAccount( contentList: List, account: LoggedAccount, ): List { return contentList.filterIsInstance() .filter { it.baseUrl == account.platform.baseUrl } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/ActivityPubContentManager.kt ================================================ package com.zhangke.fread.activitypub.app import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.activitypub.app.internal.screen.add.AddActivityPubContentScreenKey import com.zhangke.fread.status.content.AddContentAction import com.zhangke.fread.status.content.IContentManager import com.zhangke.fread.status.model.ContentConfig import com.zhangke.fread.status.model.FreadContent import com.zhangke.fread.status.model.notActivityPub import com.zhangke.fread.status.platform.BlogPlatform class ActivityPubContentManager () : IContentManager { override suspend fun addContent( platform: BlogPlatform, action: AddContentAction, ) { if (platform.protocol.notActivityPub) return action.onFinishPage() action.onOpenNewPage(AddActivityPubContentScreenKey(platform)) } override fun restoreContent(config: ContentConfig): FreadContent? { if (config !is ContentConfig.ActivityPubContent) return null return ActivityPubContent( order = config.order, name = config.name, baseUrl = config.baseUrl, tabList = buildList { addAll(config.showingTabList.map { it.toTab(false) }) addAll(config.hiddenTabList.map { it.toTab(true) }) }, accountUri = null, ) } private fun ContentConfig.ActivityPubContent.ContentTab.toTab( hide: Boolean, ): ActivityPubContent.ContentTab { return when (this) { is ContentConfig.ActivityPubContent.ContentTab.HomeTimeline -> ActivityPubContent.ContentTab.HomeTimeline( order = this.order, hide = hide, ) is ContentConfig.ActivityPubContent.ContentTab.LocalTimeline -> ActivityPubContent.ContentTab.LocalTimeline( order = this.order, hide = hide, ) is ContentConfig.ActivityPubContent.ContentTab.ListTimeline -> ActivityPubContent.ContentTab.ListTimeline( order = this.order, listId = this.listId, name = this.name, hide = hide, ) is ContentConfig.ActivityPubContent.ContentTab.PublicTimeline -> ActivityPubContent.ContentTab.PublicTimeline( order = this.order, hide = hide, ) is ContentConfig.ActivityPubContent.ContentTab.Trending -> ActivityPubContent.ContentTab.Trending( order = this.order, hide = hide, ) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/ActivityPubJsonBuilder.kt ================================================ package com.zhangke.fread.activitypub.app import com.zhangke.framework.architect.json.JsonModuleBuilder import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.status.model.FreadContent import com.zhangke.krouter.annotation.Service import kotlinx.serialization.modules.SerializersModuleBuilder import kotlinx.serialization.serializer @Service class ActivityPubJsonBuilder : JsonModuleBuilder { override fun SerializersModuleBuilder.buildSerializersModule() { polymorphic( baseClass = FreadContent::class, actualClass = ActivityPubContent::class, actualSerializer = serializer(), ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/ActivityPubModule.kt ================================================ package com.zhangke.fread.activitypub.app import com.zhangke.framework.nav.NavEntryProvider import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubAccountEntityAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubApplicationEntityAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubBlogMetaAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubContentAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubCustomEmojiEntityAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubInstanceAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubLoggedAccountAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubPlatformEntityAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubPollAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubSearchAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubTagAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubTranslationEntityAdapter import com.zhangke.fread.activitypub.app.internal.adapter.PostStatusAttachmentAdapter import com.zhangke.fread.activitypub.app.internal.adapter.RegisterApplicationEntryAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubOAuthor import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.migrate.ActivityPubContentMigrator import com.zhangke.fread.activitypub.app.internal.repo.WebFingerBaseUrlToUserIdRepo import com.zhangke.fread.activitypub.app.internal.repo.account.ActivityPubLoggedAccountRepo import com.zhangke.fread.activitypub.app.internal.repo.application.ActivityPubApplicationRepo import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import com.zhangke.fread.activitypub.app.internal.repo.platform.BlogPlatformResourceLoader import com.zhangke.fread.activitypub.app.internal.repo.platform.MastodonInstanceRepo import com.zhangke.fread.activitypub.app.internal.repo.status.ActivityPubStatusReadStateRepo import com.zhangke.fread.activitypub.app.internal.repo.status.ActivityPubTimelineStatusRepo import com.zhangke.fread.activitypub.app.internal.repo.user.UserRepo import com.zhangke.fread.activitypub.app.internal.screen.account.EditAccountInfoViewModel import com.zhangke.fread.activitypub.app.internal.screen.add.AddActivityPubContentViewModel import com.zhangke.fread.activitypub.app.internal.screen.add.select.SelectPlatformViewModel import com.zhangke.fread.activitypub.app.internal.screen.content.ActivityPubContentViewModel import com.zhangke.fread.activitypub.app.internal.screen.content.edit.EditContentConfigViewModel import com.zhangke.fread.activitypub.app.internal.screen.content.timeline.ActivityPubTimelineContainerViewModel import com.zhangke.fread.activitypub.app.internal.screen.explorer.ExplorerContainerViewModel import com.zhangke.fread.activitypub.app.internal.screen.filters.edit.EditFilterViewModel import com.zhangke.fread.activitypub.app.internal.screen.filters.list.FiltersListViewModel import com.zhangke.fread.activitypub.app.internal.screen.hashtag.HashtagTimelineContainerViewModel import com.zhangke.fread.activitypub.app.internal.screen.instance.InstanceDetailViewModel import com.zhangke.fread.activitypub.app.internal.screen.instance.about.ServerAboutViewModel import com.zhangke.fread.activitypub.app.internal.screen.instance.tags.ServerTrendsTagsViewModel import com.zhangke.fread.activitypub.app.internal.screen.list.CreatedListsViewModel import com.zhangke.fread.activitypub.app.internal.screen.list.add.AddListViewModel import com.zhangke.fread.activitypub.app.internal.screen.list.edit.EditListViewModel import com.zhangke.fread.activitypub.app.internal.screen.search.SearchStatusViewModel import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusViewModel import com.zhangke.fread.activitypub.app.internal.screen.status.post.adapter.CustomEmojiAdapter import com.zhangke.fread.activitypub.app.internal.screen.status.post.usecase.GenerateInitPostStatusUiStateUseCase import com.zhangke.fread.activitypub.app.internal.screen.status.post.usecase.PublishPostUseCase import com.zhangke.fread.activitypub.app.internal.screen.trending.TrendingStatusViewModel import com.zhangke.fread.activitypub.app.internal.screen.user.UserDetailContainerViewModel import com.zhangke.fread.activitypub.app.internal.screen.user.list.UserListType import com.zhangke.fread.activitypub.app.internal.screen.user.list.UserListViewModel import com.zhangke.fread.activitypub.app.internal.screen.user.search.SearchUserViewModel import com.zhangke.fread.activitypub.app.internal.screen.user.status.StatusListContainerViewModel import com.zhangke.fread.activitypub.app.internal.screen.user.tags.TagListViewModel import com.zhangke.fread.activitypub.app.internal.screen.user.timeline.UserTimelineContainerViewModel import com.zhangke.fread.activitypub.app.internal.source.UserSourceTransformer import com.zhangke.fread.activitypub.app.internal.uri.PlatformUriTransformer import com.zhangke.fread.activitypub.app.internal.uri.UserUriTransformer import com.zhangke.fread.activitypub.app.internal.usecase.ActivityPubAccountLogoutUseCase import com.zhangke.fread.activitypub.app.internal.usecase.GetDefaultBaseUrlUseCase import com.zhangke.fread.activitypub.app.internal.usecase.GetInstanceAnnouncementUseCase import com.zhangke.fread.activitypub.app.internal.usecase.GetServerTrendTagsUseCase import com.zhangke.fread.activitypub.app.internal.usecase.UpdateActivityPubUserListUseCase import com.zhangke.fread.activitypub.app.internal.usecase.content.GetUserCreatedListUseCase import com.zhangke.fread.activitypub.app.internal.usecase.content.ReorderActivityPubTabUseCase import com.zhangke.fread.activitypub.app.internal.usecase.emoji.GetCustomEmojiUseCase import com.zhangke.fread.activitypub.app.internal.usecase.emoji.MapCustomEmojiUseCase import com.zhangke.fread.activitypub.app.internal.usecase.media.UploadMediaAttachmentUseCase import com.zhangke.fread.activitypub.app.internal.usecase.platform.GetInstancePostStatusRulesUseCase import com.zhangke.fread.activitypub.app.internal.usecase.source.user.SearchUserSourceNoTokenUseCase import com.zhangke.fread.activitypub.app.internal.usecase.status.GetStatusContextUseCase import com.zhangke.fread.activitypub.app.internal.usecase.status.GetTimelineStatusUseCase import com.zhangke.fread.activitypub.app.internal.usecase.status.GetUserStatusUseCase import com.zhangke.fread.activitypub.app.internal.usecase.status.StatusInteractiveUseCase import com.zhangke.fread.activitypub.app.internal.usecase.status.VotePollUseCase import com.zhangke.fread.activitypub.app.internal.utils.MastodonHelper import com.zhangke.fread.common.browser.BrowserInterceptor import com.zhangke.fread.status.IStatusProvider import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.uri.FormalUri import org.koin.core.module.Module import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.bind import org.koin.dsl.module val activityPubModule = module { createPlatformModule() factoryOf(::ActivityPubNavEntryProvider) bind NavEntryProvider::class singleOf(::ActivityPubAccountManager) singleOf(::ActivityPubClientManager) singleOf(::ActivityPubOAuthor) singleOf(::LoggedAccountProvider) singleOf(::ActivityPubLoggedAccountRepo) singleOf(::MastodonHelper) factoryOf(::ActivityPubContentManager) factoryOf(::ActivityPubScreenProvider) factoryOf(::ActivityPubSearchEngine) factoryOf(::ActivityPubSourceResolver) factoryOf(::ActivityPubStatusResolver) factoryOf(::ActivityPubNotificationResolver) factoryOf(::ActivityPubPublishManager) factoryOf(::ActivityPubStartup) factoryOf(::ActivityPubUrlInterceptor) bind BrowserInterceptor::class factoryOf(::ActivityPubContentMigrator) factoryOf(::ActivityPubAccountEntityAdapter) factoryOf(::ActivityPubApplicationEntityAdapter) factoryOf(::ActivityPubBlogMetaAdapter) factoryOf(::ActivityPubContentAdapter) factoryOf(::ActivityPubCustomEmojiEntityAdapter) factoryOf(::ActivityPubInstanceAdapter) factoryOf(::ActivityPubLoggedAccountAdapter) factoryOf(::ActivityPubPlatformEntityAdapter) factoryOf(::ActivityPubPollAdapter) factoryOf(::ActivityPubSearchAdapter) factoryOf(::ActivityPubStatusAdapter) factoryOf(::ActivityPubTagAdapter) factoryOf(::ActivityPubTranslationEntityAdapter) factoryOf(::PostStatusAttachmentAdapter) factoryOf(::RegisterApplicationEntryAdapter) factoryOf(::CustomEmojiAdapter) factoryOf(::ActivityPubApplicationRepo) factoryOf(::ActivityPubPlatformRepo) factoryOf(::ActivityPubStatusReadStateRepo) factoryOf(::ActivityPubTimelineStatusRepo) factoryOf(::BlogPlatformResourceLoader) factoryOf(::MastodonInstanceRepo) factoryOf(::UserRepo) factoryOf(::WebFingerBaseUrlToUserIdRepo) factoryOf(::UserSourceTransformer) factoryOf(::PlatformUriTransformer) factoryOf(::UserUriTransformer) factoryOf(::ActivityPubAccountLogoutUseCase) factoryOf(::GetDefaultBaseUrlUseCase) factoryOf(::GetInstanceAnnouncementUseCase) factoryOf(::GetServerTrendTagsUseCase) factoryOf(::UpdateActivityPubUserListUseCase) factoryOf(::GetUserCreatedListUseCase) factoryOf(::ReorderActivityPubTabUseCase) factoryOf(::GetCustomEmojiUseCase) factoryOf(::MapCustomEmojiUseCase) factoryOf(::UploadMediaAttachmentUseCase) factoryOf(::GetInstancePostStatusRulesUseCase) factoryOf(::SearchUserSourceNoTokenUseCase) factoryOf(::GetStatusContextUseCase) factoryOf(::GetTimelineStatusUseCase) factoryOf(::GetUserStatusUseCase) factoryOf(::StatusInteractiveUseCase) factoryOf(::VotePollUseCase) factoryOf(::GenerateInitPostStatusUiStateUseCase) factoryOf(::PublishPostUseCase) viewModelOf(::EditAccountInfoViewModel) viewModelOf(::AddActivityPubContentViewModel) viewModelOf(::SelectPlatformViewModel) viewModelOf(::ActivityPubContentViewModel) viewModelOf(::EditContentConfigViewModel) viewModelOf(::ActivityPubTimelineContainerViewModel) viewModelOf(::ExplorerContainerViewModel) viewModel { EditFilterViewModel( clientManager = get(), locator = it.get(), id = it.getOrNull(), ) } viewModelOf(::FiltersListViewModel) viewModelOf(::HashtagTimelineContainerViewModel) viewModelOf(::InstanceDetailViewModel) viewModelOf(::ServerAboutViewModel) viewModelOf(::ServerTrendsTagsViewModel) viewModelOf(::CreatedListsViewModel) viewModelOf(::AddListViewModel) viewModelOf(::EditListViewModel) viewModelOf(::SearchStatusViewModel) viewModelOf(::PostStatusViewModel) viewModelOf(::TrendingStatusViewModel) viewModelOf(::UserDetailContainerViewModel) viewModel { params -> UserListViewModel( clientManager = get(), userUriTransformer = get(), webFingerBaseUrlToUserIdRepo = get(), accountEntityAdapter = get(), locator = params.values[0] as PlatformLocator, type = params.values[1] as UserListType, statusId = params.values.getOrNull(2) as String?, userUri = params.values.getOrNull(3) as FormalUri?, userId = params.values.getOrNull(4) as String?, ) } viewModelOf(::SearchUserViewModel) viewModelOf(::StatusListContainerViewModel) viewModelOf(::TagListViewModel) viewModelOf(::UserTimelineContainerViewModel) factoryOf(::ActivityPubProvider) bind IStatusProvider::class } expect fun Module.createPlatformModule() ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/ActivityPubNavEntryProvider.kt ================================================ package com.zhangke.fread.activitypub.app import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.zhangke.framework.nav.NavEntryProvider import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.screen.account.EditAccountInfoScreen import com.zhangke.fread.activitypub.app.internal.screen.account.EditAccountInfoScreenNavKey import com.zhangke.fread.activitypub.app.internal.screen.add.AddActivityPubContentScreen import com.zhangke.fread.activitypub.app.internal.screen.add.AddActivityPubContentScreenKey import com.zhangke.fread.activitypub.app.internal.screen.add.select.SelectPlatformScreen import com.zhangke.fread.activitypub.app.internal.screen.add.select.SelectPlatformScreenKey import com.zhangke.fread.activitypub.app.internal.screen.content.edit.EditContentConfigScreen import com.zhangke.fread.activitypub.app.internal.screen.content.edit.EditContentConfigScreenKey import com.zhangke.fread.activitypub.app.internal.screen.filters.edit.EditFilterScreen import com.zhangke.fread.activitypub.app.internal.screen.filters.edit.EditFilterScreenKey import com.zhangke.fread.activitypub.app.internal.screen.filters.edit.HiddenKeywordScreen import com.zhangke.fread.activitypub.app.internal.screen.filters.edit.HiddenKeywordScreenNavKey import com.zhangke.fread.activitypub.app.internal.screen.filters.list.FiltersListScreen import com.zhangke.fread.activitypub.app.internal.screen.filters.list.FiltersListScreenKey import com.zhangke.fread.activitypub.app.internal.screen.hashtag.HashtagTimelineScreen import com.zhangke.fread.activitypub.app.internal.screen.hashtag.HashtagTimelineScreenKey import com.zhangke.fread.activitypub.app.internal.screen.instance.InstanceDetailScreen import com.zhangke.fread.activitypub.app.internal.screen.instance.InstanceDetailScreenKey import com.zhangke.fread.activitypub.app.internal.screen.list.CreatedListsScreen import com.zhangke.fread.activitypub.app.internal.screen.list.CreatedListsScreenKey import com.zhangke.fread.activitypub.app.internal.screen.list.add.AddListScreen import com.zhangke.fread.activitypub.app.internal.screen.list.add.AddListScreenNavKey import com.zhangke.fread.activitypub.app.internal.screen.list.edit.EditListScreen import com.zhangke.fread.activitypub.app.internal.screen.list.edit.EditListScreenNavKey import com.zhangke.fread.activitypub.app.internal.screen.search.SearchStatusScreen import com.zhangke.fread.activitypub.app.internal.screen.search.SearchStatusScreenNavKey import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusScreen import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusScreenKey import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusScreenRoute import com.zhangke.fread.activitypub.app.internal.screen.user.UserDetailScreen import com.zhangke.fread.activitypub.app.internal.screen.user.UserDetailScreenKey import com.zhangke.fread.activitypub.app.internal.screen.user.list.UserListScreen import com.zhangke.fread.activitypub.app.internal.screen.user.list.UserListScreenKey import com.zhangke.fread.activitypub.app.internal.screen.user.search.SearchUserScreen import com.zhangke.fread.activitypub.app.internal.screen.user.search.SearchUserScreenNavKey import com.zhangke.fread.activitypub.app.internal.screen.user.status.StatusListScreen import com.zhangke.fread.activitypub.app.internal.screen.user.status.StatusListScreenKey import com.zhangke.fread.activitypub.app.internal.screen.user.tags.TagListScreen import com.zhangke.fread.activitypub.app.internal.screen.user.tags.TagListScreenKey import com.zhangke.fread.status.uri.FormalUri import kotlinx.serialization.modules.PolymorphicModuleBuilder import kotlinx.serialization.modules.subclass import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf class ActivityPubNavEntryProvider : NavEntryProvider { override fun EntryProviderScope.build() { entry { SelectPlatformScreen(koinViewModel()) } entry { key -> AddActivityPubContentScreen( platform = key.platform, viewModel = koinViewModel { parametersOf(key.platform) }, ) } entry { PostStatusScreen( viewModel = koinViewModel { parametersOf( PostStatusScreenRoute.buildParams( accountUri = it.accountUri, defaultContent = it.defaultContent, editBlog = it.editBlogJsonString, replyToBlogJsonString = it.replyingBlogJsonString, quoteBlogJsonString = it.quoteBlogJsonString, ) ) } ) } entry { key -> EditContentConfigScreen( contentId = key.contentId, viewModel = koinViewModel { parametersOf(key.contentId) }, ) } entry { InstanceDetailScreen(it.locator, koinViewModel { parametersOf(it.baseUrl) }) } entry { UserDetailScreen( viewModel = koinViewModel(), locator = it.locator, userUri = it.userUri, webFinger = it.webFinger, userId = it.userId, ) } entry { StatusListScreen(it.locator, it.type) } entry { HashtagTimelineScreen( viewModel = koinViewModel(), locator = it.locator, hashtag = it.hashtag, ) } entry { UserListScreen( viewModel = koinViewModel { parametersOf( it.locator, it.type, it.statusId, it.userUri, it.userId, ) } ) } entry { TagListScreen( viewModel = koinViewModel { parametersOf(it.locator) } ) } entry { FiltersListScreen( viewModel = koinViewModel { parametersOf(it.locator) }, locator = it.locator, ) } entry { EditFilterScreen( viewModel = koinViewModel { parametersOf(it.locator, it.id) }, id = it.id, ) } entry { CreatedListsScreen( viewModel = koinViewModel { parametersOf(it.locator) }, locator = it.locator, ) } entry { AddListScreen( viewModel = koinViewModel { parametersOf(it.locator) }, locator = it.locator, ) } entry { SearchUserScreen( viewModel = koinViewModel { parametersOf(it.locator, it.onlyFollowing) } ) } entry { EditListScreen( locator = it.locator, viewModel = koinViewModel { parametersOf(it.locator, it.serializedList) }, ) } entry { EditAccountInfoScreen( viewModel = koinViewModel { parametersOf( FormalBaseUrl.parse(it.baseUrl)!!, FormalUri.from(it.accountUri)!!, ) }, ) } entry { SearchStatusScreen( viewModel = koinViewModel { parametersOf(it.locator, it.userId) } ) } entry { HiddenKeywordScreen(it.addedKeywords) } } override fun PolymorphicModuleBuilder.polymorph() { subclass(SelectPlatformScreenKey::class) subclass(AddActivityPubContentScreenKey::class) subclass(PostStatusScreenKey::class) subclass(EditContentConfigScreenKey::class) subclass(InstanceDetailScreenKey::class) subclass(UserDetailScreenKey::class) subclass(StatusListScreenKey::class) subclass(HashtagTimelineScreenKey::class) subclass(UserListScreenKey::class) subclass(TagListScreenKey::class) subclass(FiltersListScreenKey::class) subclass(EditFilterScreenKey::class) subclass(CreatedListsScreenKey::class) subclass(AddListScreenNavKey::class) subclass(SearchUserScreenNavKey::class) subclass(EditListScreenNavKey::class) subclass(EditAccountInfoScreenNavKey::class) subclass(SearchStatusScreenNavKey::class) subclass(HiddenKeywordScreenNavKey::class) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/ActivityPubNotificationResolver.kt ================================================ package com.zhangke.fread.activitypub.app import com.zhangke.activitypub.entities.ActivityPubNotificationsEntity import com.zhangke.framework.date.DateParser import com.zhangke.framework.ktx.ifNullOrEmpty import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubAccountEntityAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.notActivityPub import com.zhangke.fread.status.notification.INotificationResolver import com.zhangke.fread.status.notification.PagedStatusNotification import com.zhangke.fread.status.notification.StatusNotification import com.zhangke.fread.status.platform.BlogPlatform import kotlinx.coroutines.async import kotlinx.coroutines.supervisorScope class ActivityPubNotificationResolver ( private val clientManager: ActivityPubClientManager, private val platformRepo: ActivityPubPlatformRepo, private val loggedAccountProvider: LoggedAccountProvider, private val accountAdapter: ActivityPubAccountEntityAdapter, private val statusAdapter: ActivityPubStatusAdapter, ) : INotificationResolver { override suspend fun getNotifications( account: LoggedAccount, type: INotificationResolver.NotificationRequestType, cursor: String?, ): Result? { if (account.platform.protocol.notActivityPub) return null val isFirstPage = cursor == null val locator = PlatformLocator(baseUrl = account.platform.baseUrl, accountUri = account.uri) val platform = platformRepo.getPlatform(locator).let { if (it.isFailure) return Result.failure(it.exceptionOrNull()!!) it.getOrThrow() } val loggedAccount = loggedAccountProvider.getAccount(locator) val notificationsRepo = clientManager.getClient(locator).notificationsRepo val types = mutableListOf() if (type == INotificationResolver.NotificationRequestType.MENTION) { types += ActivityPubNotificationsEntity.Type.MENTION } val (unreadCountResult, notificationsResult) = supervisorScope { val notificationDeferred = async { notificationsRepo.getNotifications( limit = 50, types = types, maxId = cursor, ) } val unreadCountDeferred = async { if (isFirstPage) { notificationsRepo.getUnreadNotificationCount(limit = 1000).getOrNull()?.count ?: 0 } else { 0 } } val notifications = notificationDeferred.await() val unreadCount = unreadCountDeferred.await() unreadCount to notifications } return notificationsResult.map { notifications -> val cursor = notifications.lastOrNull()?.id PagedStatusNotification( cursor = cursor, reachEnd = cursor == null, notifications = notifications.mapIndexed { index, entity -> val unread = if (isFirstPage) { index < unreadCountResult } else { false } convertNotification( locator = locator, entity = entity, unread = unread, loggedAccount = loggedAccount, platform = platform, ) } ) } } override suspend fun getNotificationUserDetail( account: LoggedAccount, users: List, ): Result>? { if (account !is ActivityPubLoggedAccount) return null val userIdList = users.mapNotNull { if (it.relationships == null) it.userId else null } if (userIdList.isEmpty()) return Result.success(emptyList()) return clientManager.getClient(account.locator) .accountRepo .getRelationships(idList = userIdList) .map { list -> users.mapNotNull { user -> val relationship = list.firstOrNull { it.id == user.userId } ?.let { accountAdapter.convertRelationship(it) } if (relationship != null) { user.copy(relationships = relationship) } else { null } } } } private fun convertNotification( locator: PlatformLocator, entity: ActivityPubNotificationsEntity, loggedAccount: ActivityPubLoggedAccount?, platform: BlogPlatform, unread: Boolean, ): StatusNotification { val createAt = DateParser.parseOrCurrent(entity.createdAt) val author = accountAdapter.toAuthor(entity.account) val status = entity.status?.let { statusAdapter.toStatusUiState( entity = it, platform = platform, locator = locator, loggedAccount = loggedAccount, ) } return when (entity.type) { ActivityPubNotificationsEntity.FAVORITE -> { if (status == null) { StatusNotification.Unknown( id = entity.id, createAt = createAt, unread = unread, locator = locator, message = "Unknown notification type: ${entity.type}", ) } else { StatusNotification.Like( id = entity.id, author = author, locator = locator, blog = status.status.intrinsicBlog, createAt = createAt, unread = unread, ) } } ActivityPubNotificationsEntity.MENTION -> { StatusNotification.Mention( id = entity.id, author = author, status = status!!, unread = unread, ) } ActivityPubNotificationsEntity.FOLLOW -> { StatusNotification.Follow( id = entity.id, author = author, locator = locator, createAt = createAt, unread = unread, ) } ActivityPubNotificationsEntity.REBLOG -> { StatusNotification.Repost( id = entity.id, author = author, locator = locator, createAt = createAt, blog = status!!.status.intrinsicBlog, unread = unread, ) } ActivityPubNotificationsEntity.STATUS -> { if (status == null) { StatusNotification.Unknown( id = entity.id, createAt = createAt, unread = unread, locator = locator, message = "Unknown notification type: ${entity.type}", ) } else { StatusNotification.NewStatus( status = status, unread = unread, ) } } ActivityPubNotificationsEntity.FOLLOW_REQUEST -> { StatusNotification.FollowRequest( id = entity.id, createAt = createAt, locator = locator, author = author, unread = unread, ) } ActivityPubNotificationsEntity.POLL -> { if (status == null) { StatusNotification.Unknown( id = entity.id, createAt = createAt, unread = unread, locator = locator, message = "Unknown notification type: ${entity.type}", ) } else { StatusNotification.Poll( id = entity.id, createAt = createAt, locator = locator, unread = unread, blog = status.status.intrinsicBlog, ) } } ActivityPubNotificationsEntity.UPDATE -> { if (status == null) { StatusNotification.Unknown( id = entity.id, createAt = createAt, unread = unread, locator = locator, message = "Unknown notification type: ${entity.type}", ) } else { StatusNotification.Update( id = entity.id, createAt = createAt, status = status, unread = unread, ) } } ActivityPubNotificationsEntity.SEVERED_RELATIONSHIPS -> { StatusNotification.SeveredRelationships( id = entity.id, createAt = createAt, locator = locator, author = author, unread = unread, reason = entity.relationshipSeveranceEvent?.targetName.ifNullOrEmpty { "Unknown" }, ) } ActivityPubNotificationsEntity.QUOTE -> { if (status == null) { StatusNotification.Unknown( id = entity.id, createAt = createAt, unread = unread, locator = locator, message = "Unknown notification type: ${entity.type}", ) } else { StatusNotification.Quote( id = entity.id, author = author, quote = status, unread = unread, ) } } ActivityPubNotificationsEntity.QUOTED_UPDATE -> { if (status == null) { StatusNotification.Unknown( id = entity.id, createAt = createAt, unread = unread, locator = locator, message = "Unknown notification type: ${entity.type}", ) } else { StatusNotification.QuoteUpdate( id = entity.id, author = author, quote = status, unread = unread, createAt = createAt, ) } } else -> StatusNotification.Unknown( id = entity.id, createAt = createAt, unread = unread, locator = locator, message = "Unknown notification type: ${entity.type}", ) } } override suspend fun rejectFollowRequest( account: LoggedAccount, requestAuthor: BlogAuthor ): Result? { if (account.platform.protocol.notActivityPub) return null if (account !is ActivityPubLoggedAccount) return null val userId = requestAuthor.userId if (userId.isNullOrEmpty()) { return Result.failure(IllegalArgumentException("Request author userId is empty")) } val role = PlatformLocator(baseUrl = account.platform.baseUrl, accountUri = account.uri) return clientManager.getClient(role) .accountRepo .rejectFollowRequest(userId) .map { } } override suspend fun acceptFollowRequest( account: LoggedAccount, requestAuthor: BlogAuthor ): Result? { if (account.platform.protocol.notActivityPub) return null if (account !is ActivityPubLoggedAccount) return null val userId = requestAuthor.userId if (userId.isNullOrEmpty()) { return Result.failure(IllegalArgumentException("Request author userId is empty")) } val role = PlatformLocator(baseUrl = account.platform.baseUrl, accountUri = account.uri) return clientManager.getClient(role) .accountRepo .authorizeFollowRequest(userId) .map { } } override suspend fun updateUnreadNotification( account: LoggedAccount, notificationLastReadId: String ): Result? { if (account.platform.protocol.notActivityPub) return null val role = PlatformLocator(baseUrl = account.platform.baseUrl, accountUri = account.uri) return clientManager.getClient(role) .markerRepo .saveMarkers(notificationLastReadId = notificationLastReadId) .map { } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/ActivityPubProvider.kt ================================================ package com.zhangke.fread.activitypub.app import com.zhangke.fread.status.IStatusProvider import com.zhangke.fread.status.account.IAccountManager import com.zhangke.fread.status.content.IContentManager import com.zhangke.fread.status.notification.INotificationResolver import com.zhangke.fread.status.screen.IStatusScreenProvider import com.zhangke.fread.status.status.IStatusResolver class ActivityPubProvider ( internalContentManager: ActivityPubContentManager, internalScreenProvider: ActivityPubScreenProvider, internalSearchEngine: ActivityPubSearchEngine, internalStatusResolver: ActivityPubStatusResolver, internalSourceResolver: ActivityPubSourceResolver, internalAccountManager: ActivityPubAccountManager, notificationResolver: ActivityPubNotificationResolver, activityPubPublishManager: ActivityPubPublishManager, ) : IStatusProvider { override val contentManager: IContentManager = internalContentManager override val screenProvider: IStatusScreenProvider = internalScreenProvider override val searchEngine = internalSearchEngine override val statusResolver: IStatusResolver = internalStatusResolver override val statusSourceResolver = internalSourceResolver override val accountManager: IAccountManager = internalAccountManager override val notificationResolver: INotificationResolver = notificationResolver override val publishManager = activityPubPublishManager } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/ActivityPubPublishManager.kt ================================================ package com.zhangke.fread.activitypub.app import com.zhangke.activitypub.entities.ActivityPubInstanceConfigurationEntity import com.zhangke.activitypub.entities.ActivityPubInstanceEntity import com.zhangke.framework.utils.initLocale import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusAttachment import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusMediaAttachmentFile import com.zhangke.fread.activitypub.app.internal.screen.status.post.usecase.PublishPostUseCase import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.PublishBlogRules import com.zhangke.fread.status.model.notActivityPub import com.zhangke.fread.status.publish.IPublishBlogManager import com.zhangke.fread.status.publish.PublishingMedia import com.zhangke.fread.status.publish.PublishingPost class ActivityPubPublishManager( private val clientManager: ActivityPubClientManager, private val publishPost: PublishPostUseCase, ) : IPublishBlogManager { private val baseUrlToInstanceInfo = mutableMapOf() override suspend fun getPublishBlogRules(account: LoggedAccount): Result? { if (account.platform.protocol.notActivityPub) return null baseUrlToInstanceInfo[account.platform.baseUrl.toString()]?.let { instance -> return Result.success(instance.configuration.toRules()) } val locator = PlatformLocator(accountUri = account.uri, baseUrl = account.platform.baseUrl) return clientManager.getClient(locator) .instanceRepo .getInstanceInformation() .map { baseUrlToInstanceInfo[account.platform.baseUrl.toString()] = it it.configuration.toRules() } } private fun ActivityPubInstanceConfigurationEntity.toRules(): PublishBlogRules { return PublishBlogRules( maxCharacters = this.statuses?.maxCharacters ?: 200, maxMediaCount = this.statuses?.maxMediaAttachments ?: 4, maxPollOptions = this.polls?.maxOptions ?: 4, supportSpoiler = true, supportPoll = true, maxLanguageCount = 1, mediaAltMaxCharacters = 1500, ) } override suspend fun publish( account: LoggedAccount, post: PublishingPost ): Result? { if (account.platform.protocol.notActivityPub) return null val apAccount = account as ActivityPubLoggedAccount return publishPost( account = apAccount, content = post.content, attachment = post.medias.convert(), sensitive = post.sensitive, warningContent = post.warningText, visibility = post.visibility, language = initLocale(post.languageCode), ) } private fun List.convert(): PostStatusAttachment? { if (this.isEmpty()) return null if (this.first().isVideo) { return PostStatusAttachment.Video(this.first().convert()) } return PostStatusAttachment.Image(this.map { it.convert() }) } private fun PublishingMedia.convert(): PostStatusMediaAttachmentFile { return PostStatusMediaAttachmentFile.LocalFile( file = this.file, alt = this.alt, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/ActivityPubScreenProvider.kt ================================================ package com.zhangke.fread.activitypub.app import androidx.navigation3.runtime.NavKey import com.zhangke.framework.nav.Tab import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.screen.add.select.SelectPlatformScreenKey import com.zhangke.fread.activitypub.app.internal.screen.content.ActivityPubContentTab import com.zhangke.fread.activitypub.app.internal.screen.content.edit.EditContentConfigScreenKey import com.zhangke.fread.activitypub.app.internal.screen.explorer.ExplorerContainerTab import com.zhangke.fread.activitypub.app.internal.screen.hashtag.HashtagTimelineScreenKey import com.zhangke.fread.activitypub.app.internal.screen.instance.InstanceDetailScreenKey import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusScreenKey import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusScreenRoute import com.zhangke.fread.activitypub.app.internal.screen.user.UserDetailScreenKey import com.zhangke.fread.activitypub.app.internal.screen.user.list.UserListScreenKey import com.zhangke.fread.activitypub.app.internal.screen.user.list.UserListType import com.zhangke.fread.activitypub.app.internal.uri.UserUriTransformer import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.model.FreadContent import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusProviderProtocol import com.zhangke.fread.status.model.notActivityPub import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.screen.IStatusScreenProvider import com.zhangke.fread.status.uri.FormalUri class ActivityPubScreenProvider( private val userUriTransformer: UserUriTransformer, private val loggedAccountProvider: LoggedAccountProvider, ) : IStatusScreenProvider { override fun getReplyBlogScreen(locator: PlatformLocator, blog: Blog): NavKey? { return openPublishPostScreen( locator = locator, blog = blog, ) { accountUri, blog -> PostStatusScreenRoute.buildReplyScreen( accountUri = accountUri, blog = blog, ) } } override fun getEditBlogScreen(locator: PlatformLocator, blog: Blog): NavKey? { return openPublishPostScreen( locator = locator, blog = blog, ) { accountUri, blog -> PostStatusScreenRoute.buildEditBlogRoute( accountUri = accountUri, blog = blog, ) } } override fun getQuoteBlogScreen(locator: PlatformLocator, blog: Blog): NavKey? { return openPublishPostScreen( locator = locator, blog = blog, ) { accountUri, blog -> PostStatusScreenRoute.buildQuoteBlogScreen( accountUri = accountUri, quoteBlog = blog, ) } } private fun openPublishPostScreen( locator: PlatformLocator, blog: Blog, builder: (FormalUri, Blog) -> NavKey, ): NavKey? { if (blog.platform.protocol.notActivityPub) return null var accountUri = locator.accountUri if (accountUri == null) { accountUri = loggedAccountProvider.getAccount(locator.baseUrl)?.uri } accountUri ?: return null return builder(accountUri, blog) } override fun getContentScreen(content: FreadContent, isLatestTab: Boolean): Tab? { if (content !is ActivityPubContent) return null return ActivityPubContentTab(content.id, isLatestTab) } override fun getEditContentConfigScreenScreen(content: FreadContent): NavKey? { if (content !is ActivityPubContent) return null return EditContentConfigScreenKey(content.id) } override fun getUserDetailScreen( locator: PlatformLocator, uri: FormalUri, userId: String?, ): NavKey? { userUriTransformer.parse(uri) ?: return null return UserDetailScreenKey(locator = locator, userUri = uri, userId = userId) } override fun getUserDetailScreen( locator: PlatformLocator, webFinger: WebFinger, protocol: StatusProviderProtocol, ): NavKey? { if (protocol.notActivityPub) return null return UserDetailScreenKey(locator = locator, webFinger = webFinger) } override fun getUserDetailScreen( locator: PlatformLocator, did: String, protocol: StatusProviderProtocol ): NavKey? { return null } override fun getTagTimelineScreen( locator: PlatformLocator, tag: String, protocol: StatusProviderProtocol, ): NavKey? { if (protocol.notActivityPub) return null return HashtagTimelineScreenKey( locator = locator, hashtag = tag.removePrefix("#"), ) } override fun getBlogFavouritedScreen( locator: PlatformLocator, blog: Blog, protocol: StatusProviderProtocol ): NavKey? { if (protocol.notActivityPub) return null return UserListScreenKey( locator = locator, type = UserListType.FAVOURITES, statusId = blog.id, ) } override fun getBlogBoostedScreen( locator: PlatformLocator, blog: Blog, protocol: StatusProviderProtocol ): NavKey? { if (protocol.notActivityPub) return null return UserListScreenKey( locator = locator, type = UserListType.REBLOGS, statusId = blog.id, ) } override fun getInstanceDetailScreen( locator: PlatformLocator, protocol: StatusProviderProtocol, baseUrl: FormalBaseUrl, ): NavKey? { if (protocol.notActivityPub) return null return InstanceDetailScreenKey(locator = locator, baseUrl = baseUrl) } override fun getExplorerTab(locator: PlatformLocator, platform: BlogPlatform): Tab? { if (platform.protocol.notActivityPub) return null return ExplorerContainerTab(locator = locator, platform = platform) } override fun getAddContentScreen(protocol: StatusProviderProtocol): NavKey? { if (protocol.notActivityPub) return null return SelectPlatformScreenKey } override fun getPublishScreen(account: LoggedAccount, text: String): NavKey? { if (account !is ActivityPubLoggedAccount) return null return PostStatusScreenKey(accountUri = account.uri, defaultContent = text) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/ActivityPubSearchEngine.kt ================================================ package com.zhangke.fread.activitypub.app import com.zhangke.activitypub.api.SearchRepo import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubAccountEntityAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubSearchAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubTagAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import com.zhangke.fread.activitypub.app.internal.usecase.source.user.SearchUserSourceNoTokenUseCase import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.Hashtag import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusProviderProtocol import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.model.notActivityPub import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.search.ISearchEngine import com.zhangke.fread.status.search.SearchResult import com.zhangke.fread.status.search.SearchedPlatform import com.zhangke.fread.status.source.StatusSource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow class ActivityPubSearchEngine ( private val searchUserSource: SearchUserSourceNoTokenUseCase, private val clientManager: ActivityPubClientManager, private val platformRepo: ActivityPubPlatformRepo, private val searchAdapter: ActivityPubSearchAdapter, private val statusAdapter: ActivityPubStatusAdapter, private val hashtagAdapter: ActivityPubTagAdapter, private val accountAdapter: ActivityPubAccountEntityAdapter, private val loggedAccountProvider: LoggedAccountProvider, ) : ISearchEngine { override suspend fun search( locator: PlatformLocator, query: String ): Result> { val account = locator.accountUri?.let { loggedAccountProvider.getAccount(it) } return doSearch(locator) { searchRepo, platform -> searchRepo.query(query = query, resolve = true).map { searchAdapter.toSearchResult(it, platform, locator, account) } } } override suspend fun searchStatus( locator: PlatformLocator, query: String, maxId: String?, ): Result> { val account = locator.accountUri?.let { loggedAccountProvider.getAccount(it) } return doSearch(locator) { searchRepo, blogPlatform -> searchRepo.queryStatus( query = query, maxId = maxId, ).map { list -> list.map { statusAdapter.toStatusUiState( entity = it, platform = blogPlatform, locator = locator, loggedAccount = account, ) } } } } override suspend fun searchHashtag( locator: PlatformLocator, query: String, offset: Int?, ): Result> { return doSearch(locator) { searchRepo, _ -> searchRepo.queryHashtags( query = query, offset = offset, ).map { list -> list.map { hashtagAdapter.adapt(it) } } } } override suspend fun searchAuthor( locator: PlatformLocator, query: String, offset: Int?, ): Result> { return doSearch(locator) { searchRepo, _ -> searchRepo.queryAccount( query = query, offset = offset, ).map { list -> list.map { accountAdapter.toAuthor(it) } } } } private suspend fun doSearch( locator: PlatformLocator, onSearch: suspend (SearchRepo, BlogPlatform) -> Result>, ): Result> { val platformResult = platformRepo.getPlatform(locator) if (platformResult.isFailure) { return Result.failure(platformResult.exceptionOrNull()!!) } val platform = platformResult.getOrThrow() val searchRepo = clientManager.getClient(locator).searchRepo return onSearch(searchRepo, platform) } override suspend fun searchSourceNoToken(query: String): Result> { return searchUserSource(query) } override suspend fun searchPlatform( locator: PlatformLocator, query: String ): Flow>? { return flow { platformRepo.searchPlatformSnapshotFromLocal(query) .map { SearchedPlatform.Snapshot(it) } .let { emit(it) } FormalBaseUrl.parse(query) ?.let { platformRepo.getPlatform(it) } ?.onSuccess { emit(listOf(SearchedPlatform.Platform(it))) } platformRepo.searchPlatformFromServer(query) .map { list -> list.map { SearchedPlatform.Snapshot(it) } } .onSuccess { emit(it) } } } override suspend fun searchStatusByUrl( protocol: StatusProviderProtocol, locator: PlatformLocator, url: String ): Result? { if (protocol.notActivityPub) return null val account = locator.accountUri?.let { loggedAccountProvider.getAccount(it) } return doSearch(locator) { searchRepo, blogPlatform -> searchRepo.queryStatus(query = url, resolve = true) .map { statusEntities -> statusEntities.firstOrNull()?.let { statusAdapter.toStatusUiState( entity = it, platform = blogPlatform, locator = locator, loggedAccount = account, ) } }.map { listOf(it) } }.map { it.firstOrNull() } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/ActivityPubSourceResolver.kt ================================================ package com.zhangke.fread.activitypub.app import com.zhangke.fread.activitypub.app.internal.repo.user.UserRepo import com.zhangke.fread.activitypub.app.internal.uri.UserUriTransformer import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.source.IStatusSourceResolver import com.zhangke.fread.status.source.StatusSource import com.zhangke.fread.status.uri.FormalUri class ActivityPubSourceResolver( private val userRepo: UserRepo, private val userUriTransformer: UserUriTransformer, ) : IStatusSourceResolver { override suspend fun resolveSourceByUri(uri: FormalUri): Result { val userUriInsights = userUriTransformer.parse(uri) ?: return Result.success(null) val locator = PlatformLocator(baseUrl = userUriInsights.baseUrl) return userRepo.getUserSource( locator = locator, userUriInsights = userUriInsights, ) } override suspend fun resolveRssSource(rssUrl: String): Result? { return null } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/ActivityPubStartup.kt ================================================ package com.zhangke.fread.activitypub.app import com.zhangke.framework.architect.coroutines.ApplicationScope import com.zhangke.framework.collections.container import com.zhangke.framework.module.ModuleStartup import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubContentAdapter import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.activitypub.app.internal.migrate.ActivityPubContentMigrator import com.zhangke.fread.activitypub.app.internal.repo.account.ActivityPubLoggedAccountRepo import com.zhangke.fread.common.content.FreadContentRepo import kotlinx.coroutines.delay import kotlinx.coroutines.launch class ActivityPubStartup( private val contentRepo: FreadContentRepo, private val accountRepo: ActivityPubLoggedAccountRepo, private val contentAdapter: ActivityPubContentAdapter, private val contentMigrator: ActivityPubContentMigrator, private val loggedAccountRepo: ActivityPubLoggedAccountRepo, ) : ModuleStartup { override fun onAppCreate() { ApplicationScope.launch { contentMigrator.migrate() loggedAccountRepo.initialize() accountRepo.onNewAccountFlow.collect { account -> delay(500) val contentExist = contentRepo.getAllContent() .filterIsInstance() .container { it.baseUrl == account.baseUrl } if (!contentExist) { contentAdapter.createContent( platform = account.platform, maxOrder = contentRepo.getMaxOrder(), ).let { contentRepo.insertContent(it) } } } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/ActivityPubStatusResolver.kt ================================================ package com.zhangke.fread.activitypub.app import com.zhangke.activitypub.api.AccountsRepo import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubTranslationEntityAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.repo.WebFingerBaseUrlToUserIdRepo import com.zhangke.fread.activitypub.app.internal.uri.UserUriTransformer import com.zhangke.fread.activitypub.app.internal.usecase.status.GetStatusContextUseCase import com.zhangke.fread.activitypub.app.internal.usecase.status.GetUserStatusUseCase import com.zhangke.fread.activitypub.app.internal.usecase.status.StatusInteractiveUseCase import com.zhangke.fread.activitypub.app.internal.usecase.status.VotePollUseCase import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.blog.BlogPoll import com.zhangke.fread.status.blog.BlogTranslation import com.zhangke.fread.status.model.PagedData import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusActionType import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.model.notActivityPub import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.status.IStatusResolver import com.zhangke.fread.status.status.model.Status import com.zhangke.fread.status.status.model.StatusContext import com.zhangke.fread.status.uri.FormalUri class ActivityPubStatusResolver ( private val clientManager: ActivityPubClientManager, private val getUserStatus: GetUserStatusUseCase, private val userUriTransformer: UserUriTransformer, private val statusInteractive: StatusInteractiveUseCase, private val activityPubStatusAdapter: ActivityPubStatusAdapter, private val getStatusContextUseCase: GetStatusContextUseCase, private val votePollUseCase: VotePollUseCase, private val webFingerBaseUrlToUserIdRepo: WebFingerBaseUrlToUserIdRepo, private val loggedAccountProvider: LoggedAccountProvider, private val translationAdapter: ActivityPubTranslationEntityAdapter, ) : IStatusResolver { override suspend fun getStatus( locator: PlatformLocator, blogId: String?, blogUri: String?, platform: BlogPlatform ): Result? { if (platform.protocol.notActivityPub) return null val statusRepo = clientManager.getClient(locator).statusRepo val loggedAccount = loggedAccountProvider.getAccount(locator) if (blogId.isNullOrEmpty()) return Result.failure(IllegalArgumentException("blogId is null or empty!")) return statusRepo.getStatuses(blogId) .mapCatching { entity -> if (entity == null) throw IllegalArgumentException("Can't find status(${blogId})") activityPubStatusAdapter.toStatusUiState( entity = entity, platform = platform, locator = locator, loggedAccount = loggedAccount, ) } } override suspend fun getStatusList( uri: FormalUri, limit: Int, maxId: String?, ): Result>? { val userInsights = userUriTransformer.parse(uri) ?: return null val locator = PlatformLocator(baseUrl = userInsights.baseUrl) return getUserStatus( locator = locator, userInsights = userInsights, limit = limit, maxId = maxId, ).map { PagedData(it, it.lastOrNull()?.status?.id) } } override suspend fun interactive( locator: PlatformLocator, status: Status, type: StatusActionType, ): Result? { if (status.notThisPlatform()) return null return statusInteractive(locator, status, type) } override suspend fun votePoll( locator: PlatformLocator, blog: Blog, votedOption: List ): Result? { if (blog.platform.protocol.notActivityPub) return null return votePollUseCase(locator, blog, votedOption) } override suspend fun getStatusContext( locator: PlatformLocator, status: Status ): Result? { if (status.notThisPlatform()) return null return getStatusContextUseCase(locator, status) } private fun Status.notThisPlatform(): Boolean { return this.platform.protocol.notActivityPub } override suspend fun follow(locator: PlatformLocator, target: BlogAuthor): Result? { return updateRelationship( locator = locator, target = target, updater = { this.follow(it) } ) } override suspend fun unfollow(locator: PlatformLocator, target: BlogAuthor): Result? { return updateRelationship( locator = locator, target = target, updater = { this.unfollow(it) } ) } private suspend fun updateRelationship( locator: PlatformLocator, target: BlogAuthor, updater: suspend AccountsRepo.(userId: String) -> Result<*>, ): Result? { userUriTransformer.parse(target.uri) ?: return null val userId = if (target.userId.isNullOrEmpty()) { val userIdResult = webFingerBaseUrlToUserIdRepo.getUserId(target.webFinger, locator) if (userIdResult.isFailure) return Result.failure(userIdResult.exceptionOrNull()!!) userIdResult.getOrThrow() } else { target.userId!! } return clientManager.getClient(locator) .accountRepo .updater(userId) .map {} } override suspend fun isFollowing( locator: PlatformLocator, target: BlogAuthor ): Result? { userUriTransformer.parse(target.uri) ?: return null val userId = if (target.userId.isNullOrEmpty()) { val userIdResult = webFingerBaseUrlToUserIdRepo.getUserId(target.webFinger, locator) if (userIdResult.isFailure) return Result.failure(userIdResult.exceptionOrNull()!!) userIdResult.getOrThrow() } else { target.userId!! } return clientManager.getClient(locator) .accountRepo .getRelationships(listOf(userId)) .map { it.firstOrNull()?.following ?: false } } override suspend fun translate( locator: PlatformLocator, status: Status, lan: String, ): Result? { if (status.notThisPlatform()) return null return clientManager.getClient(locator) .statusRepo .translate(status.intrinsicBlog.id, lan) .map { translationAdapter.toTranslation(it) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/ActivityPubUrlInterceptor.kt ================================================ package com.zhangke.fread.activitypub.app import androidx.navigation3.runtime.NavKey import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.network.HttpScheme import com.zhangke.framework.network.SimpleUri import com.zhangke.framework.network.addProtocolSuffixIfNecessary import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubAccountEntityAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import com.zhangke.fread.activitypub.app.internal.screen.user.UserDetailScreenKey import com.zhangke.fread.common.browser.BrowserInterceptor import com.zhangke.fread.common.browser.InterceptorResult import com.zhangke.fread.common.content.FreadContentRepo import com.zhangke.fread.commonbiz.shared.screen.status.context.StatusContextScreenNavKey import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.createActivityPubProtocol import com.zhangke.fread.status.platform.BlogPlatform class ActivityPubUrlInterceptor( private val platformRepo: ActivityPubPlatformRepo, private val clientManager: ActivityPubClientManager, private val loggedAccountProvider: LoggedAccountProvider, private val accountEntityAdapter: ActivityPubAccountEntityAdapter, private val activityPubStatusAdapter: ActivityPubStatusAdapter, private val contentRepo: FreadContentRepo, ) : BrowserInterceptor { override suspend fun intercept( locator: PlatformLocator?, url: String, isFromExternal: Boolean, ): InterceptorResult { val uri = SimpleUri.parse(url) ?: return InterceptorResult.CanNotIntercept if (!HttpScheme.validate(uri.scheme.orEmpty().addProtocolSuffixIfNecessary())) { return InterceptorResult.CanNotIntercept } val isProfileUrl = isProfileUrl(uri) val isStatusUrl = isMastodonStatusUrl(uri) if (!isProfileUrl && !isStatusUrl) { if (isFromExternal) { val platform = parsePlatform(uri) if (platform != null) { val content = contentRepo.getAllContent() .mapNotNull { it as? ActivityPubContent } .firstOrNull { it.baseUrl == platform.baseUrl } if (content != null) { return InterceptorResult.SwitchHomeContent(content) } } } return InterceptorResult.CanNotIntercept } var account: ActivityPubLoggedAccount? = null val fixedLocator = if (locator?.accountUri != null) { locator } else { val accountList = loggedAccountProvider.getAllAccounts() if (accountList.isEmpty()) { val baseUrl = locator?.baseUrl ?: FormalBaseUrl.parse(uri.host!!) ?: return InterceptorResult.CanNotIntercept locator ?: PlatformLocator(baseUrl) } else if (accountList.size == 1) { account = accountList.first() PlatformLocator(account.platform.baseUrl, account.uri) } else { return InterceptorResult.RequireSelectAccount(createActivityPubProtocol()) } } parseStatus(fixedLocator, uri, account)?.let { return InterceptorResult.SuccessWithOpenNewScreen(it) } parseMastodonProfile(fixedLocator, uri)?.let { return InterceptorResult.SuccessWithOpenNewScreen(it) } return InterceptorResult.CanNotIntercept } private fun isProfileUrl(uri: SimpleUri): Boolean { val path = uri.path?.removePrefix("/") ?: return false if (path.isEmpty()) return false if (uri.queries.isNotEmpty()) return false if (path.contains("?") || path.contains("/")) return false if (!path.startsWith("@")) return false return true } private suspend fun parseMastodonProfile(locator: PlatformLocator, uri: SimpleUri): NavKey? { if (!isProfileUrl(uri)) return null val path = uri.path?.removePrefix("/") ?: return null val baseUrl = FormalBaseUrl.parse(uri.toString()) ?: return null val acct = "$path@${baseUrl.host}" val accountRepo = clientManager.getClient(locator).accountRepo val account = accountRepo.lookup(acct).getOrNull() ?: return null val webFinger = accountEntityAdapter.toWebFinger(account) return UserDetailScreenKey(locator = locator, webFinger = webFinger) } private fun isMastodonStatusUrl(uri: SimpleUri): Boolean { return parseMastodonStatusParams(uri) != null } private fun parseMastodonStatusParams(uri: SimpleUri): String? { FormalBaseUrl.parse(uri.toString()) ?: return null if (uri.queries.isNotEmpty()) return null val path = uri.path?.removePrefix("/") ?: return null val array = path.split("/") if (array.size != 2) return null val acct = array[0] val statusId = array[1] if (!acct.startsWith("@")) return null if (statusId.isEmpty()) return null return statusId } private suspend fun parseStatus( locator: PlatformLocator, uri: SimpleUri, account: ActivityPubLoggedAccount?, ): NavKey? { val statusId = parseMastodonStatusParams(uri) ?: return null val baseUrl = FormalBaseUrl.parse(uri.toString()) ?: return null val client = clientManager.getClient(locator) val statusRepo = client.statusRepo val platform = platformRepo.getPlatform(baseUrl).getOrNull() ?: return null val status = if (locator.baseUrl != baseUrl) { // other platform, by search val searchRepo = client.searchRepo val searchedStatusResult = searchRepo.queryStatus(uri.toString(), resolve = true).getOrNull() ?: return null if (searchedStatusResult.size != 1) return null searchedStatusResult.first().let { activityPubStatusAdapter.toStatusUiState( entity = it, platform = platform, locator = locator, loggedAccount = account, ) } } else { // same platform statusRepo.getStatuses(statusId).getOrNull()?.let { activityPubStatusAdapter.toStatusUiState( entity = it, platform = platform, locator = locator, loggedAccount = account, ) } } return status?.let { StatusContextScreenNavKey.create(it) } } private suspend fun parsePlatform(uri: SimpleUri): BlogPlatform? { val baseUrl = FormalBaseUrl.parse(uri.toString()) ?: return null if (uri.queries.isNotEmpty()) return null return platformRepo.getPlatform(baseUrl).getOrNull() } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/DataTrackingElements.kt ================================================ package com.zhangke.fread.activitypub.app.internal object ApNotificationElements { const val ALL = "apNotificationAll" const val MENTION = "apNotificationMention" } object ActivityPubDataElements { const val INSTANCE_DETAIL_OPEN_IN_BROWSER = "instanceDetailOpenInBrowser" const val INSTANCE_DETAIL_COPY_LINK = "instanceDetailCopyLink" const val USER_DETAIL_OPEN_IN_BROWSER = "userDetailOpenInBrowser" const val USER_DETAIL_COPY_LINK = "userDetailCopyLink" const val USER_DETAIL_OPEN_ORIGINAL_INSTANCE = "userDetailOpenOriginalInstance" } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/ActivityPubAccountEntityAdapter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.activitypub.entities.ActivityPubRelationshipEntity import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.uri.UserUriTransformer import com.zhangke.fread.analytics.reportToLogger import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.LoggedAccountDetail import com.zhangke.fread.status.model.Relationships import com.zhangke.fread.status.uri.FormalUri class ActivityPubAccountEntityAdapter ( private val userUriTransformer: UserUriTransformer, private val emojiEntityAdapter: ActivityPubCustomEmojiEntityAdapter, ) { fun toAuthor( entity: ActivityPubAccountEntity, ): BlogAuthor { val webFinger = toWebFinger(entity) return BlogAuthor( uri = toUri(entity), webFinger = webFinger, name = entity.displayName, handle = webFinger.toString(), description = entity.note, avatar = entity.avatar, emojis = entity.emojis.map(emojiEntityAdapter::toEmoji), userId = entity.id, bot = entity.bot, banner = entity.header, followersCount = entity.followersCount.toLong(), followingCount = entity.followingCount.toLong(), statusesCount = entity.statusesCount.toLong(), relationships = null, ) } fun convertLoggedAccountDetail( account: ActivityPubLoggedAccount, ): LoggedAccountDetail { val author = BlogAuthor( uri = account.uri, webFinger = account.webFinger, name = account.userName, handle = account.webFinger.toString(), description = account.description.orEmpty(), avatar = account.avatar, emojis = account.emojis, userId = account.id, bot = account.bot, banner = account.banner, followersCount = account.followersCount, followingCount = account.followingCount, statusesCount = account.statusesCount, relationships = null, ) return LoggedAccountDetail( account = account, author = author, ) } fun toUri(entity: ActivityPubAccountEntity): FormalUri { val webFinger = toWebFinger(entity) return userUriTransformer.build(webFinger, FormalBaseUrl.parse(entity.url)!!) } fun toWebFinger(account: ActivityPubAccountEntity): WebFinger { try { WebFinger.create(account.acct)?.let { return it } WebFinger.create(account.url)!!.let { return it } } catch (e: Throwable) { reportToLogger("ToWebFingerException") { put("acct", account.acct) put("url", account.url) put("displayName", account.displayName) } throw e } } fun convertRelationship(entity: ActivityPubRelationshipEntity): Relationships { return Relationships( following = entity.following, followedBy = entity.followedBy, blocking = entity.blocking, blockedBy = entity.blockedBy, muting = entity.muting, requested = entity.requested, requestedBy = entity.requestedBy, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/ActivityPubApplicationEntityAdapter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.fread.activitypub.app.internal.model.ActivityPubApplication import com.zhangke.fread.activitypub.app.internal.db.ActivityPubApplicationEntity class ActivityPubApplicationEntityAdapter () { fun toApplication(entity: ActivityPubApplicationEntity) = ActivityPubApplication( baseUrl = entity.baseUrl, id = entity.id, name = entity.name, website = entity.website, redirectUri = entity.redirectUri, clientId = entity.clientId, clientSecret = entity.clientSecret, vapidKey = entity.vapidKey, ) fun toEntity(application: ActivityPubApplication) = ActivityPubApplicationEntity( baseUrl = application.baseUrl, id = application.id, name = application.name, website = application.website, redirectUri = application.redirectUri, clientId = application.clientId, clientSecret = application.clientSecret, vapidKey = application.vapidKey, ) } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/ActivityPubBlogMetaAdapter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.activitypub.entities.ActivityPubMediaMetaEntity import com.zhangke.fread.status.blog.BlogMediaMeta import com.zhangke.fread.status.blog.BlogMediaType class ActivityPubBlogMetaAdapter () { fun adapt( type: BlogMediaType, entity: ActivityPubMediaMetaEntity, ): BlogMediaMeta? { return when (type) { BlogMediaType.IMAGE -> entity.toImageMeta() BlogMediaType.GIFV -> entity.toGifvMeta() BlogMediaType.VIDEO -> entity.toVideoMeta() BlogMediaType.AUDIO -> entity.toAudioMeta() else -> null } } private fun ActivityPubMediaMetaEntity.toImageMeta() = BlogMediaMeta.ImageMeta( original = original?.toImageLayoutMeta(), small = original?.toImageLayoutMeta(), focus = focus?.toImageFocusMeta(), ) private fun ActivityPubMediaMetaEntity.LayoutMeta.toImageLayoutMeta() = BlogMediaMeta.ImageMeta.LayoutMeta( width = width?.toLong(), height = height?.toLong(), size = size, aspect = aspect, ) private fun ActivityPubMediaMetaEntity.FocusMeta.toImageFocusMeta() = BlogMediaMeta.ImageMeta.FocusMeta( x = x, y = y, ) private fun ActivityPubMediaMetaEntity.toVideoMeta() = BlogMediaMeta.VideoMeta( length = length, duration = duration, fps = fps, size = size, width = width?.toLong(), height = height?.toLong(), aspect = aspect, audioEncode = audioEncode, audioBitrate = audioBitrate, audioChannels = audioChannels, original = original?.toVideoLayoutMeta(), small = small?.toVideoLayoutMeta(), ) private fun ActivityPubMediaMetaEntity.LayoutMeta.toVideoLayoutMeta() = BlogMediaMeta.VideoMeta.LayoutMeta( width = width?.toLong(), height = height?.toLong(), size = size, frameRate = frameRate, duration = duration, aspect = aspect, bitrate = bitrate, ) private fun ActivityPubMediaMetaEntity.toGifvMeta() = BlogMediaMeta.GifvMeta( length = length, duration = duration, fps = fps, size = size, width = width, height = height, aspect = aspect, original = original?.toGifvLayoutMeta(), small = small?.toGifvLayoutMeta(), ) private fun ActivityPubMediaMetaEntity.LayoutMeta.toGifvLayoutMeta() = BlogMediaMeta.GifvMeta.LayoutMeta( width = width?.toLong(), height = height?.toLong(), size = size, frameRate = frameRate, duration = duration, aspect = aspect, bitrate = bitrate, ) private fun ActivityPubMediaMetaEntity.toAudioMeta() = BlogMediaMeta.AudioMeta( length = length, duration = duration, audioEncode = audioEncode, audioBitrate = audioBitrate, audioChannels = audioChannels, original = original?.toAudioLayoutMeta(), ) private fun ActivityPubMediaMetaEntity.LayoutMeta.toAudioLayoutMeta() = BlogMediaMeta.AudioMeta.FrameMeta( duration = duration, bitrate = bitrate, ) } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/ActivityPubContentAdapter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.status.platform.BlogPlatform class ActivityPubContentAdapter () { fun createContent( platform: BlogPlatform, maxOrder: Int, ): ActivityPubContent { return ActivityPubContent( name = platform.name, baseUrl = platform.baseUrl, order = maxOrder + 1, tabList = buildInitialTabConfigList(), accountUri = null, ) } private fun buildInitialTabConfigList(): List { val tabList = mutableListOf() tabList += ActivityPubContent.ContentTab.HomeTimeline(0) tabList += ActivityPubContent.ContentTab.LocalTimeline(1) tabList += ActivityPubContent.ContentTab.PublicTimeline(2) tabList += ActivityPubContent.ContentTab.Trending(3) return tabList } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/ActivityPubCustomEmojiEntityAdapter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.activitypub.entities.ActivityPubCustomEmojiEntity import com.zhangke.fread.activitypub.app.internal.model.CustomEmoji import com.zhangke.fread.status.model.Emoji class ActivityPubCustomEmojiEntityAdapter () { fun toCustomEmoji(entity: ActivityPubCustomEmojiEntity) = CustomEmoji( shortcode = entity.shortcode, url = entity.url, staticUrl = entity.staticUrl, visibleInPicker = entity.visibleInPicker, category = entity.category ?: "Default", ) fun toEmoji(entity: ActivityPubCustomEmojiEntity): Emoji { return Emoji( shortcode = entity.shortcode, url = entity.url, staticUrl = entity.staticUrl, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/ActivityPubInstanceAdapter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.activitypub.entities.ActivityPubInstanceEntity import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.status.model.createActivityPubProtocol import com.zhangke.fread.activitypub.app.internal.uri.PlatformUriTransformer import com.zhangke.fread.status.platform.BlogPlatform class ActivityPubInstanceAdapter ( private val platformUriTransformer: PlatformUriTransformer, ) { fun toPlatform( baseUrl: FormalBaseUrl, instance: ActivityPubInstanceEntity ): BlogPlatform { // 此处需要注意的是,ActivityPubInstanceEntity#domain 并不一定准确。 // 例如 https://mastodon.jakewharton.com/api/v2/instance // 他的 domain 是 jakewharton.com,而不是 mastodon.jakewharton.com val uri = platformUriTransformer.build(baseUrl) return BlogPlatform( uri = uri.toString(), baseUrl = baseUrl, name = instance.title, description = instance.description, protocol = createActivityPubProtocol(), thumbnail = instance.thumbnail.url, supportsQuotePost = instance.supportsQuotePost, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/ActivityPubLoggedAccountAdapter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.activitypub.entities.ActivityPubInstanceEntity import com.zhangke.activitypub.entities.ActivityPubTokenEntity import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.uri.UserUriTransformer import com.zhangke.fread.analytics.reportToLogger import com.zhangke.fread.status.platform.BlogPlatform class ActivityPubLoggedAccountAdapter ( private val instanceAdapter: ActivityPubInstanceAdapter, private val userUriTransformer: UserUriTransformer, private val emojiEntityAdapter: ActivityPubCustomEmojiEntityAdapter, ) { suspend fun createFromAccount( baseUrl: FormalBaseUrl, instance: ActivityPubInstanceEntity, account: ActivityPubAccountEntity, token: ActivityPubTokenEntity, ): ActivityPubLoggedAccount { return createFromAccount( platform = instanceAdapter.toPlatform(baseUrl, instance), account = account, token = token, ) } fun createFromAccount( platform: BlogPlatform, account: ActivityPubAccountEntity, token: ActivityPubTokenEntity, ): ActivityPubLoggedAccount { val webFinger = accountToWebFinger(account, platform.baseUrl) return ActivityPubLoggedAccount( userId = account.id, uri = userUriTransformer.build(webFinger, platform.baseUrl), webFinger = webFinger, platform = platform, baseUrl = platform.baseUrl, userName = account.displayName, description = account.note, avatar = account.avatar, url = account.url, token = token, banner = account.header, note = account.note, bot = account.bot, followersCount = account.followersCount.toLong(), followingCount = account.followingCount.toLong(), statusesCount = account.statusesCount.toLong(), emojis = account.emojis.map(emojiEntityAdapter::toEmoji), ) } private fun accountToWebFinger( account: ActivityPubAccountEntity, baseUrl: FormalBaseUrl, ): WebFinger { try { WebFinger.create(account.acct, baseUrl)?.let { return it } WebFinger.create(account.url)!!.let { return it } } catch (e: Throwable) { e.printStackTrace() reportToLogger("WebFingerCreateError") { put("acct", account.acct) put("url", account.url) } throw e } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/ActivityPubPlatformEntityAdapter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.activitypub.entities.ActivityPubInstanceEntity import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.db.ActivityPubInstanceInfoEntity import com.zhangke.fread.activitypub.app.internal.uri.PlatformUriTransformer class ActivityPubPlatformEntityAdapter ( private val uriTransformer: PlatformUriTransformer, ) { fun toEntity( baseUrl: FormalBaseUrl, entity: ActivityPubInstanceEntity ): ActivityPubInstanceInfoEntity { return ActivityPubInstanceInfoEntity( uri = uriTransformer.build(baseUrl).toString(), baseUrl = baseUrl, instanceEntity = entity, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/ActivityPubPollAdapter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.activitypub.entities.ActivityPubPollEntity import com.zhangke.fread.status.blog.BlogPoll class ActivityPubPollAdapter () { fun adapt(entity: ActivityPubPollEntity): BlogPoll { return BlogPoll( id = entity.id, expiresAt = entity.expiresAt, expired = entity.expired, multiple = entity.multiple, votesCount = entity.votesCount, votersCount = entity.votersCount, voted = entity.voted, options = entity.options.mapIndexed { index, item -> item.convertToPoll(index) }, ownVotes = entity.ownVotes ?: emptyList(), ) } private fun ActivityPubPollEntity.Option.convertToPoll(index: Int): BlogPoll.Option { return BlogPoll.Option( index = index, title = title, votesCount = votesCount, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/ActivityPubSearchAdapter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.activitypub.entities.ActivityPubSearchEntity import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.search.SearchResult class ActivityPubSearchAdapter ( private val accountEntityAdapter: ActivityPubAccountEntityAdapter, private val hashtagAdapter: ActivityPubTagAdapter, private val statusAdapter: ActivityPubStatusAdapter, ) { suspend fun toSearchResult( entity: ActivityPubSearchEntity, platform: BlogPlatform, locator: PlatformLocator, account: ActivityPubLoggedAccount?, ): List { val authorList = entity.accounts.map { SearchResult.Author(accountEntityAdapter.toAuthor(it)) } val hashtagList = entity.hashtags.map { SearchResult.SearchedHashtag(hashtagAdapter.adapt(it)) } val statusList = entity.statuses.map { SearchResult.SearchedStatus(statusAdapter.toStatusUiState(it, platform, locator, account)) } return authorList + hashtagList + statusList } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/ActivityPubStatusAdapter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.activitypub.entities.ActivityPubFilterEntity import com.zhangke.activitypub.entities.ActivityPubFilterResultEntity import com.zhangke.activitypub.entities.ActivityPubMediaAttachmentEntity import com.zhangke.activitypub.entities.ActivityPubQuoteApprovalEntity import com.zhangke.activitypub.entities.ActivityPubStatusEntity import com.zhangke.framework.date.DateParser import com.zhangke.framework.ktx.ifNullOrEmpty import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.common.utils.formatDefault import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.blog.BlogEmbed import com.zhangke.fread.status.blog.BlogMedia import com.zhangke.fread.status.blog.BlogMediaType import com.zhangke.fread.status.blog.CurrentUserQuoteApproval import com.zhangke.fread.status.blog.PostingApplication import com.zhangke.fread.status.model.BlogFiltered import com.zhangke.fread.status.model.BlogTranslationUiState import com.zhangke.fread.status.model.HashtagInStatus import com.zhangke.fread.status.model.Mention import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.model.StatusVisibility import com.zhangke.fread.status.model.createActivityPubProtocol import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.status.model.Status class ActivityPubStatusAdapter ( private val activityPubAccountEntityAdapter: ActivityPubAccountEntityAdapter, private val metaAdapter: ActivityPubBlogMetaAdapter, private val pollAdapter: ActivityPubPollAdapter, private val emojiEntityAdapter: ActivityPubCustomEmojiEntityAdapter, ) { fun toStatusUiState( status: Status, locator: PlatformLocator, logged: Boolean, isOwner: Boolean, ): StatusUiState { return StatusUiState( status = status, locator = locator, logged = logged, isOwner = isOwner, blogTranslationState = BlogTranslationUiState( support = status.intrinsicBlog.supportTranslate, translating = false, showingTranslation = false, blogTranslation = null, ), ) } fun toStatusUiState( status: Status, locator: PlatformLocator, loggedAccount: ActivityPubLoggedAccount?, ): StatusUiState { return toStatusUiState( status = status, locator = locator, logged = loggedAccount != null, isOwner = loggedAccount?.webFinger?.equalsDomain(status.intrinsicBlog.author.webFinger) == true, ) } fun toStatusUiState( entity: ActivityPubStatusEntity, platform: BlogPlatform, locator: PlatformLocator, loggedAccount: ActivityPubLoggedAccount?, ): StatusUiState { val status = toStatus(entity, platform) return toStatusUiState(status, locator, loggedAccount) } fun toStatusUiState( entity: ActivityPubStatusEntity, platform: BlogPlatform, locator: PlatformLocator, isOwner: Boolean, logged: Boolean, ): StatusUiState { val status = toStatus(entity, platform) return toStatusUiState(status, locator, logged = logged, isOwner = isOwner) } fun toStatus( entity: ActivityPubStatusEntity, platform: BlogPlatform, ): Status { return if (entity.reblog != null) { transformReblog(entity, platform) } else { transformNewBlog(entity, platform) } } private fun transformNewBlog( entity: ActivityPubStatusEntity, platform: BlogPlatform, ): Status.NewBlog { val blog = getBlogAndInteractions(entity, platform) return Status.NewBlog(blog) } private fun transformReblog( entity: ActivityPubStatusEntity, platform: BlogPlatform, ): Status.Reblog { val blog = getBlogAndInteractions(entity.reblog!!, platform) return Status.Reblog( author = activityPubAccountEntityAdapter.toAuthor(entity.account), id = entity.id, createAt = DateParser.parseOrCurrent(entity.createdAt), reblog = blog, ) } private fun getBlogAndInteractions( entity: ActivityPubStatusEntity, platform: BlogPlatform ): Blog { val statusAuthor = activityPubAccountEntityAdapter.toAuthor(entity.account) return transformBlog(entity, platform, statusAuthor) } private fun transformBlog( entity: ActivityPubStatusEntity, platform: BlogPlatform, author: BlogAuthor, ): Blog { val emojis = entity.emojis.map(emojiEntityAdapter::toEmoji) val createAt = DateParser.parseOrCurrent(entity.createdAt) return Blog( id = entity.id, author = author, title = null, description = null, content = entity.content.orEmpty(), sensitive = entity.sensitive, spoilerText = entity.spoilerText, createAt = createAt, formattedCreateAt = createAt.formatDefault(), url = entity.url.ifNullOrEmpty { entity.uri }, link = entity.url.ifNullOrEmpty { entity.uri }, language = entity.language, like = Blog.Like( support = true, liked = entity.favourited, likedCount = entity.favouritesCount.toLong() ), forward = Blog.Forward( support = true, forward = entity.reblogged, forwardCount = entity.reblogsCount.toLong(), ), bookmark = Blog.Bookmark( support = true, bookmarked = entity.bookmarked, ), reply = Blog.Reply( support = true, repliesCount = entity.repliesCount.toLong(), ), quote = buildQuote(entity, platform), supportEdit = true, isReply = !entity.inReplyToId.isNullOrEmpty(), platform = platform, mediaList = entity.mediaAttachments?.map { it.toBlogMedia() } ?: emptyList(), poll = entity.poll?.let(pollAdapter::adapt), emojis = emojis, pinned = entity.pinned == true, facets = emptyList(), supportTranslate = true, mentions = entity.mentions.mapNotNull { it.toMention() }, tags = entity.tags.map { it.toTag() }, visibility = entity.visibility.convertActivityPubVisibility(), embeds = buildEmbed(entity, platform), editedAt = entity.editedAt?.let { DateParser.parseOrCurrent(it) }, application = entity.application?.toApplication(), filtered = entity.filtered?.map { it.toFiltered() }, ) } private fun String.convertActivityPubVisibility(): StatusVisibility { return when (this) { ActivityPubStatusEntity.VISIBILITY_PUBLIC -> StatusVisibility.PUBLIC ActivityPubStatusEntity.VISIBILITY_UNLISTED -> StatusVisibility.UNLISTED ActivityPubStatusEntity.VISIBILITY_PRIVATE -> StatusVisibility.PRIVATE ActivityPubStatusEntity.VISIBILITY_DIRECT -> StatusVisibility.DIRECT else -> StatusVisibility.PUBLIC } } private fun ActivityPubMediaAttachmentEntity.toBlogMedia(): BlogMedia { val mediaType = convertMediaType(type) return BlogMedia( id = id, url = url.orEmpty(), type = mediaType, previewUrl = previewUrl, remoteUrl = remoteUrl, description = description, meta = this.meta?.let { metaAdapter.adapt(mediaType, it) }, blurhash = blurhash, ) } private fun ActivityPubStatusEntity.Tag.toTag(): HashtagInStatus { return HashtagInStatus( name = name, url = url, protocol = createActivityPubProtocol(), ) } private fun convertMediaType(type: String): BlogMediaType { return when (type) { ActivityPubMediaAttachmentEntity.TYPE_IMAGE -> BlogMediaType.IMAGE ActivityPubMediaAttachmentEntity.TYPE_AUDIO -> BlogMediaType.AUDIO ActivityPubMediaAttachmentEntity.TYPE_VIDEO -> BlogMediaType.VIDEO ActivityPubMediaAttachmentEntity.TYPE_GIFV -> BlogMediaType.GIFV ActivityPubMediaAttachmentEntity.TYPE_UNKNOWN -> BlogMediaType.UNKNOWN else -> throw IllegalArgumentException("Unsupported media type(${type})!") } } private fun ActivityPubStatusEntity.Mention.toMention(): Mention? { val webFinger = WebFinger.create(acct) ?: WebFinger.create(this.url) ?: return null return Mention( id = id, username = username, url = url, webFinger = webFinger, protocol = createActivityPubProtocol(), ) } private fun buildEmbed( entity: ActivityPubStatusEntity, platform: BlogPlatform, ): List { val list = mutableListOf() entity.card?.toEmbed()?.let { list += it } if (entity.quote != null) { val quotedStatus = entity.quote?.quotedStatus if (quotedStatus != null) { val statusAuthor = activityPubAccountEntityAdapter.toAuthor(quotedStatus.account) val quotedBlog = transformBlog(quotedStatus, platform, statusAuthor) list += BlogEmbed.Blog(quotedBlog) } else { list += BlogEmbed.UnavailableQuote( reason = entity.quote?.state.orEmpty(), blogId = entity.quote?.quotedStatusId, ) } } return list } private fun ActivityPubStatusEntity.PreviewCard.toEmbed(): BlogEmbed { return BlogEmbed.Link( url = url, title = title, description = description, video = type == ActivityPubStatusEntity.PreviewCard.TYPE_VIDEO, authorName = authorName, authorUrl = authorUrl, providerName = providerName, providerUrl = providerUrl, html = html, width = width, height = height, image = image, embedUrl = embedUrl, blurhash = blurhash, ) } private fun buildQuote( entity: ActivityPubStatusEntity, blogPlatform: BlogPlatform, ): Blog.Quote { val currentUserApproval = entity.quoteApproval?.currentUser?.toApproval() return Blog.Quote( support = blogPlatform.supportsQuotePost == true, enabled = currentUserApproval?.quotable ?: false, currentUserApproval = currentUserApproval, ) } private fun String.toApproval(): CurrentUserQuoteApproval { return when (this) { ActivityPubQuoteApprovalEntity.CURRENT_USER_AUTOMATIC -> CurrentUserQuoteApproval.AUTOMATIC ActivityPubQuoteApprovalEntity.CURRENT_USER_MANUAL -> CurrentUserQuoteApproval.MANUAL ActivityPubQuoteApprovalEntity.CURRENT_USER_DENIED -> CurrentUserQuoteApproval.DENIED else -> CurrentUserQuoteApproval.UNKNOWN } } private fun ActivityPubStatusEntity.Application.toApplication(): PostingApplication { return PostingApplication( name = name, website = website, ) } private fun ActivityPubFilterResultEntity.toFiltered(): BlogFiltered { //warn, hide, blur return BlogFiltered( id = this.filter.id, title = this.filter.title, action = when (this.filter.filterAction) { ActivityPubFilterEntity.FILTER_ACTION_WARN -> BlogFiltered.FilterAction.WARN ActivityPubFilterEntity.FILTER_ACTION_KEYWORDS -> BlogFiltered.FilterAction.HIDE else -> BlogFiltered.FilterAction.BLUR }, keywordMatches = keywordMatches ?: emptyList(), ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/ActivityPubTagAdapter.kt ================================================ @file:OptIn(ExperimentalTime::class) package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.activitypub.entities.ActivityPubTagEntity import com.zhangke.framework.composable.textOf import com.zhangke.fread.status.model.createActivityPubProtocol import com.zhangke.fread.common.utils.getCurrentInstant import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.Hashtag import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import kotlin.time.ExperimentalTime class ActivityPubTagAdapter () { suspend fun adapt(entity: ActivityPubTagEntity): Hashtag { val yesterdayTimeInMillis = getYesterdayTimeInMillis() val pass2DayUses = entity.history .filter { it.day * 1000 >= yesterdayTimeInMillis } .map { it.accounts } .takeIf { it.isNotEmpty() } ?.reduce { acc, i -> acc + i } .toString() return Hashtag( name = "#${entity.name}", url = entity.url, description = textOf(LocalizedString.activity_pub_trends_tag_description, pass2DayUses), following = entity.following, history = convertHistoryList(entity.history), protocol = createActivityPubProtocol(), ) } private fun getYesterdayTimeInMillis(): Long { val timeZone = TimeZone.currentSystemDefault() val today = getCurrentInstant().toLocalDateTime(timeZone) // 获取昨天的日期 return LocalDateTime(today.year, today.month, today.dayOfMonth, 0, 0, 0) .toInstant(timeZone) .toEpochMilliseconds() } private fun convertHistoryList( list: List ): Hashtag.History { if (list.isEmpty()) { return Hashtag.History( history = emptyList(), max = 0F, min = 0F, ) } val history = list.map { it.uses.toFloat() } val min = history.min() val max = history.max() val height = max - min val padding = height * 0.1F val bottom = 0F.coerceAtLeast(min - padding) val top = max + height return Hashtag.History( history = history, max = top, min = bottom, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/ActivityPubTranslationEntityAdapter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.activitypub.entities.ActivityPubTranslationEntity import com.zhangke.fread.status.blog.BlogTranslation class ActivityPubTranslationEntityAdapter () { fun toTranslation(entity: ActivityPubTranslationEntity): BlogTranslation { return BlogTranslation( content = entity.content, spoilerText = entity.spoilerText, poll = entity.poll?.toPol(), attachments = entity.mediaAttachments?.map { it.toAttachment() }, detectedSourceLanguage = entity.detectedSourceLanguage, provider = entity.provider, ) } private fun ActivityPubTranslationEntity.Poll.toPol(): BlogTranslation.Poll { return BlogTranslation.Poll( id = this.id, options = this.options.map { BlogTranslation.Poll.Option(it.title) }, ) } private fun ActivityPubTranslationEntity.Attachment.toAttachment(): BlogTranslation.Attachment { return BlogTranslation.Attachment( id = this.id, description = this.description, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/PostStatusAttachmentAdapter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.activitypub.entities.ActivityPubPollRequestEntity import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusAttachment class PostStatusAttachmentAdapter () { fun toPollRequest(poll: PostStatusAttachment.Poll): ActivityPubPollRequestEntity { return ActivityPubPollRequestEntity( options = poll.optionList, multiple = poll.multiple, expiresIn = poll.duration.inWholeSeconds, hideTotals = false, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/QuoteApprovalPolicyExt.kt ================================================ package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.activitypub.entities.ActivityPubPostStatusRequestEntity import com.zhangke.activitypub.entities.ActivityPubQuoteApprovalEntity import com.zhangke.fread.status.model.QuoteApprovalPolicy val QuoteApprovalPolicy.apCode: String get() = when (this) { QuoteApprovalPolicy.PUBLIC -> ActivityPubPostStatusRequestEntity.QUOTE_APPROVAL_POLICY_PUBLIC QuoteApprovalPolicy.FOLLOWERS -> ActivityPubPostStatusRequestEntity.QUOTE_APPROVAL_POLICY_FOLLOWERS QuoteApprovalPolicy.FOLLOWING -> ActivityPubPostStatusRequestEntity.QUOTE_APPROVAL_POLICY_PUBLIC QuoteApprovalPolicy.NOBODY -> ActivityPubPostStatusRequestEntity.QUOTE_APPROVAL_POLICY_NOBODY } fun String.toQuoteApprovalPolicy(): QuoteApprovalPolicy = when (this) { ActivityPubPostStatusRequestEntity.QUOTE_APPROVAL_POLICY_PUBLIC -> QuoteApprovalPolicy.PUBLIC ActivityPubPostStatusRequestEntity.QUOTE_APPROVAL_POLICY_FOLLOWERS -> QuoteApprovalPolicy.FOLLOWERS ActivityPubPostStatusRequestEntity.QUOTE_APPROVAL_POLICY_NOBODY -> QuoteApprovalPolicy.NOBODY else -> QuoteApprovalPolicy.PUBLIC } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/RegisterApplicationEntryAdapter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.activitypub.entities.RegisterApplicationEntry import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.model.ActivityPubApplication class RegisterApplicationEntryAdapter () { fun toApplication( entity: RegisterApplicationEntry, baseUrl: FormalBaseUrl, ) = ActivityPubApplication( baseUrl = baseUrl, id = entity.id, name = entity.name, clientId = entity.clientId.orEmpty(), clientSecret = entity.clientSecret.orEmpty(), redirectUri = entity.redirectUri, vapidKey = entity.vapidKey.orEmpty(), website = entity.website.orEmpty(), ) } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/adapter/StatusVisibilityExt.kt ================================================ package com.zhangke.fread.activitypub.app.internal.adapter import com.zhangke.activitypub.entities.ActivityPubStatusVisibilityEntity import com.zhangke.fread.status.model.StatusVisibility fun StatusVisibility.toEntityVisibility(): ActivityPubStatusVisibilityEntity { return when (this) { StatusVisibility.PUBLIC -> ActivityPubStatusVisibilityEntity.PUBLIC StatusVisibility.UNLISTED -> ActivityPubStatusVisibilityEntity.UNLISTED StatusVisibility.PRIVATE -> ActivityPubStatusVisibilityEntity.PRIVATE StatusVisibility.DIRECT -> ActivityPubStatusVisibilityEntity.DIRECT } } fun String.toStatusVisibility(): StatusVisibility { return when (this) { ActivityPubStatusVisibilityEntity.PUBLIC.code -> StatusVisibility.PUBLIC ActivityPubStatusVisibilityEntity.UNLISTED.code -> StatusVisibility.UNLISTED ActivityPubStatusVisibilityEntity.PRIVATE.code -> StatusVisibility.PRIVATE ActivityPubStatusVisibilityEntity.DIRECT.code -> StatusVisibility.DIRECT else -> throw IllegalArgumentException("Unknown visibility code: $this") } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/auth/ActivityPubClientManager.kt ================================================ package com.zhangke.fread.activitypub.app.internal.auth import com.zhangke.activitypub.ActivityPubClient import com.zhangke.activitypub.entities.ActivityPubTokenEntity import com.zhangke.framework.architect.http.createHttpClientEngine import com.zhangke.framework.architect.json.globalJson import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.status.model.PlatformLocator class ActivityPubClientManager ( private val loggedAccountProvider: LoggedAccountProvider, ) { private val cachedClient = mutableMapOf() private val httpClientEngine by lazy { createHttpClientEngine() } fun clearCache() { cachedClient.clear() } fun getClient(locator: PlatformLocator): ActivityPubClient { cachedClient[locator]?.let { return it } return createClient( baseUrl = locator.baseUrl, tokenProvider = { if (locator.accountUri != null) { loggedAccountProvider.getAccount(locator.accountUri!!)?.token } else { loggedAccountProvider.getAccount(locator.baseUrl)?.token } }, ).also { cachedClient[locator] = it } } fun getClientNoAccount(baseUrl: FormalBaseUrl): ActivityPubClient { return createClient( baseUrl = baseUrl, tokenProvider = { null }, ) } private fun createClient( baseUrl: FormalBaseUrl, tokenProvider: () -> ActivityPubTokenEntity?, ): ActivityPubClient { return ActivityPubClient( baseUrl = "${baseUrl}/", engine = httpClientEngine, json = globalJson, tokenProvider = tokenProvider, onAuthorizeFailed = { }, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/auth/ActivityPubOAuthor.kt ================================================ package com.zhangke.fread.activitypub.app.internal.auth import com.zhangke.activitypub.api.ActivityPubScope import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.toast.toast import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubLoggedAccountAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubPlatformEntityAdapter import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.activitypub.app.internal.db.ActivityPubDatabases import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.repo.account.ActivityPubLoggedAccountRepo import com.zhangke.fread.activitypub.app.internal.repo.application.ActivityPubApplicationRepo import com.zhangke.fread.common.browser.OAuthHandler import com.zhangke.fread.common.content.FreadContentRepo import com.zhangke.fread.common.di.ApplicationCoroutineScope import com.zhangke.fread.common.utils.getCurrentTimeMillis import kotlinx.coroutines.launch /** * Created by ZhangKe on 2022/12/4. */ class ActivityPubOAuthor( private val repo: ActivityPubLoggedAccountRepo, private val applicationRepo: ActivityPubApplicationRepo, private val clientManager: ActivityPubClientManager, private val accountAdapter: ActivityPubLoggedAccountAdapter, private val platformEntityAdapter: ActivityPubPlatformEntityAdapter, private val activityPubDatabases: ActivityPubDatabases, private val applicationScope: ApplicationCoroutineScope, private val oAuthHandler: OAuthHandler, private val freadContentRepo: FreadContentRepo, ) { internal fun startOauth( baseUrl: FormalBaseUrl, ) = applicationScope.launch { val app = applicationRepo.getApplicationByBaseUrl(baseUrl) if (app == null) { toast("Application not registered") return@launch } val client = clientManager.getClientNoAccount(baseUrl) val oauthUrl = client.oauthRepo.buildOAuthUrl( baseUrl = baseUrl.toString(), clientId = app.clientId, redirectUri = app.redirectUri, ) val code = try { oAuthHandler.startOAuth(oauthUrl) } catch (e: Exception) { toast(e.message) return@launch } val account = try { val instance = client.instanceRepo.getInstanceInformation().getOrThrow() activityPubDatabases.getPlatformDao() .insert(platformEntityAdapter.toEntity(baseUrl, instance)) val token = client.oauthRepo.getToken( code = code, clientId = app.clientId, clientSecret = app.clientSecret, redirectUri = app.redirectUri, scopeList = ActivityPubScope.ALL, ).getOrThrow() val accountEntity = client.accountRepo.verifyCredentials(token.accessToken).getOrThrow() accountAdapter.createFromAccount(baseUrl, instance, accountEntity, token) } catch (e: Exception) { toast(e.message) return@launch } insertAccount(account) } private suspend fun insertAccount(account: ActivityPubLoggedAccount) { repo.insert(account, getCurrentTimeMillis()) val contentList = freadContentRepo.getAllContent().filterIsInstance() val addedContent = contentList.firstOrNull { account.baseUrl == it.baseUrl && it.accountUri == null } if (addedContent != null) { freadContentRepo.delete(addedContent.id) freadContentRepo.insertContent(addedContent.copy(accountUri = account.uri)) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/auth/LoggedAccountProvider.kt ================================================ package com.zhangke.fread.activitypub.app.internal.auth import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.uri.FormalUri import io.github.charlietap.leftright.LeftRight /** * 用于解除 [ActivityPubClientManager] 以及 * [com.zhangke.fread.activitypub.app.ActivityPubAccountManager] 之间的依赖关系。 */ class LoggedAccountProvider () { // About LeftRight: https://github.com/CharlieTap/cachemap private val accountSet = LeftRight>(::mutableSetOf) fun updateAccounts(accountList: List) { accountSet.mutate { set -> set.clear() set.addAll(accountList) } } fun getAccount(userUri: FormalUri): ActivityPubLoggedAccount? { return accountSet.read { set -> set.find { it.uri == userUri } } } fun getAccount(baseUrl: FormalBaseUrl): ActivityPubLoggedAccount? { val accountList = accountSet.read { set -> set.filter { it.baseUrl.equalsDomain(baseUrl) } } return accountList.firstOrNull() } fun getAccount(locator: PlatformLocator): ActivityPubLoggedAccount? { if (locator.accountUri != null) { return getAccount(locator.accountUri!!) } return getAccount(locator.baseUrl) } fun getAllAccounts(): List { return accountSet.read { set -> set.toList() } } fun removeAccount(uri: FormalUri) { accountSet.mutate { set -> set.find { it.uri == uri }?.let { set.remove(it) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/composable/ActivityPubTabNames.kt ================================================ package com.zhangke.fread.activitypub.app.internal.composable import androidx.compose.runtime.Composable import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.stringResource internal object ActivityPubTabNames { val homeTimeline: String @Composable get() = stringResource(LocalizedString.activity_pub_content_tab_home) val publicTimeline: String @Composable get() = stringResource(LocalizedString.activity_pub_content_tab_public_timeline) val localTimeline: String @Composable get() = stringResource(LocalizedString.activity_pub_content_tab_local_timeline) val trending: String @Composable get() = stringResource(LocalizedString.activity_pub_content_tab_trending) } @Composable internal fun ActivityPubContent.ContentTab.tabName(): String { return when (this) { is ActivityPubContent.ContentTab.HomeTimeline -> ActivityPubTabNames.homeTimeline is ActivityPubContent.ContentTab.PublicTimeline -> ActivityPubTabNames.publicTimeline is ActivityPubContent.ContentTab.LocalTimeline -> ActivityPubTabNames.localTimeline is ActivityPubContent.ContentTab.Trending -> ActivityPubTabNames.trending is ActivityPubContent.ContentTab.ListTimeline -> this.name } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/content/ActivityPubContent.kt ================================================ package com.zhangke.fread.activitypub.app.internal.content import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.security.Md5 import com.zhangke.fread.commonbiz.mastodon_logo import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.FreadContent import com.zhangke.fread.status.uri.FormalUri import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.painterResource @Serializable data class ActivityPubContent( override val name: String, override val order: Int, val baseUrl: FormalBaseUrl, val tabList: List, override val accountUri: FormalUri? = null, ) : FreadContent { private val _id: String by lazy { if (accountUri == null) { Md5.md5(baseUrl.toString()) } else { Md5.md5(baseUrl.toString() + accountUri.toString()) } } override val id: String get() = _id override fun newOrder(newOrder: Int): FreadContent { return copy(order = newOrder) } @Composable override fun Subtitle(account: LoggedAccount?) { Row(verticalAlignment = Alignment.CenterVertically) { Image( modifier = Modifier.size(14.dp), painter = painterResource(com.zhangke.fread.commonbiz.Res.drawable.mastodon_logo), contentDescription = null, ) Text( modifier = Modifier .padding(start = 4.dp) .align(Alignment.Bottom), text = account?.prettyHandle ?: baseUrl.host, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @Serializable sealed interface ContentTab { val order: Int val hide: Boolean fun newOrder(order: Int): ContentTab fun updateHide(hide: Boolean): ContentTab @Serializable data class HomeTimeline( override val order: Int, override val hide: Boolean = false, ) : ContentTab { override fun updateHide(hide: Boolean): ContentTab { return copy(hide = hide) } override fun newOrder(order: Int): ContentTab { return copy(order = order) } } @Serializable data class LocalTimeline( override val order: Int, override val hide: Boolean = false, ) : ContentTab { override fun updateHide(hide: Boolean): ContentTab { return copy(hide = hide) } override fun newOrder(order: Int): ContentTab { return copy(order = order) } } @Serializable data class PublicTimeline( override val order: Int, override val hide: Boolean = false, ) : ContentTab { override fun updateHide(hide: Boolean): ContentTab { return copy(hide = hide) } override fun newOrder(order: Int): ContentTab { return copy(order = order) } } @Serializable data class Trending( override val order: Int, override val hide: Boolean = false, ) : ContentTab { override fun updateHide(hide: Boolean): ContentTab { return copy(hide = hide) } override fun newOrder(order: Int): ContentTab { return copy(order = order) } } @Serializable data class ListTimeline( val listId: String, val name: String, override val order: Int, override val hide: Boolean = false, ) : ContentTab { override fun updateHide(hide: Boolean): ContentTab { return copy(hide = hide) } override fun newOrder(order: Int): ContentTab { return copy(order = order) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/ActivityPubApplicationTable.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db import androidx.room.Dao import androidx.room.Delete import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query import com.zhangke.framework.network.FormalBaseUrl private const val TABLE_NAME = "application_list" @Entity(tableName = TABLE_NAME) data class ActivityPubApplicationEntity( @PrimaryKey val baseUrl: FormalBaseUrl, val id: String, val name: String, val website: String, val redirectUri: String, val clientId: String, val clientSecret: String, val vapidKey: String, ) @Dao interface ActivityPubApplicationsDao { @Query("SELECT * FROM $TABLE_NAME WHERE baseUrl=:baseUrl") suspend fun queryByBaseUrl(baseUrl: FormalBaseUrl): ActivityPubApplicationEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(applicationEntity: ActivityPubApplicationEntity) @Delete suspend fun delete(applicationEntity: ActivityPubApplicationEntity) } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/ActivityPubDatabases.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db import androidx.room.ConstructedBy import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.RoomDatabaseConstructor import androidx.room.TypeConverters import com.zhangke.fread.activitypub.app.internal.db.converter.ActivityPubInstanceEntityConverter import com.zhangke.fread.activitypub.app.internal.db.converter.ActivityPubUserTokenConverter import com.zhangke.fread.activitypub.app.internal.db.converter.EmojiListConverter import com.zhangke.fread.activitypub.app.internal.db.converter.FormalBaseUrlConverter import com.zhangke.fread.activitypub.app.internal.db.converter.PlatformEntityTypeConverter import com.zhangke.fread.activitypub.app.internal.db.old.OldActivityPubLoggedAccountEntity import com.zhangke.fread.activitypub.app.internal.db.old.OldActivityPubLoggerAccountDao import com.zhangke.fread.common.utils.WebFingerConverter private const val DB_VERSION = 1 @TypeConverters( WebFingerConverter::class, PlatformEntityTypeConverter::class, ActivityPubUserTokenConverter::class, ActivityPubInstanceEntityConverter::class, FormalBaseUrlConverter::class, EmojiListConverter::class, ) @Database( entities = [ OldActivityPubLoggedAccountEntity::class, ActivityPubApplicationEntity::class, ActivityPubInstanceInfoEntity::class, WebFingerBaseurlToIdEntity::class, ], version = DB_VERSION, exportSchema = false ) @ConstructedBy(ActivityPubDatabasesConstructor::class) abstract class ActivityPubDatabases : RoomDatabase() { abstract fun getLoggedAccountDao(): OldActivityPubLoggerAccountDao abstract fun getApplicationDao(): ActivityPubApplicationsDao abstract fun getPlatformDao(): ActivityPubPlatformDao abstract fun getUserIdDao(): WebFingerBaseurlToIdDao companion object { internal const val DB_NAME = "ActivityPubStatusProvider" } } // The Room compiler generates the `actual` implementations. @Suppress("NO_ACTUAL_FOR_EXPECT") expect object ActivityPubDatabasesConstructor : RoomDatabaseConstructor { override fun initialize(): ActivityPubDatabases } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/ActivityPubLoggedAccountDatabase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db import androidx.room.ConstructedBy import androidx.room.Dao import androidx.room.Database import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.RoomDatabase import androidx.room.RoomDatabaseConstructor import androidx.room.TypeConverters import com.zhangke.fread.activitypub.app.internal.db.converter.ActivityPubLoggedAccountConverter import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import kotlinx.coroutines.flow.Flow private const val DB_VERSION = 1 private const val TABLE_NAME = "logged_accounts" @Entity(tableName = TABLE_NAME) data class ActivityPubLoggedAccountEntity( @PrimaryKey val uri: String, val account: ActivityPubLoggedAccount, val addedTimestamp: Long, ) @Dao interface ActivityPubLoggedAccountDao { @Query("SELECT * FROM $TABLE_NAME ORDER BY addedTimestamp") fun queryAllFlow(): Flow> @Query("SELECT * FROM $TABLE_NAME WHERE uri=:uri") fun observeAccount(uri: String): Flow @Query("SELECT * FROM $TABLE_NAME ORDER BY addedTimestamp") suspend fun queryAll(): List @Query("SELECT * FROM $TABLE_NAME WHERE uri=:uri") suspend fun queryByUri(uri: String): ActivityPubLoggedAccountEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entry: ActivityPubLoggedAccountEntity) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entries: List) @Query("DELETE FROM $TABLE_NAME WHERE uri=:uri") suspend fun deleteByUri(uri: String) @Query("DELETE FROM $TABLE_NAME") suspend fun nukeTable() } @TypeConverters(ActivityPubLoggedAccountConverter::class) @Database( entities = [ ActivityPubLoggedAccountEntity::class, ], version = DB_VERSION, exportSchema = false, ) @ConstructedBy(ActivityPubLoggedAccountDatabaseConstructor::class) abstract class ActivityPubLoggedAccountDatabase : RoomDatabase() { abstract fun getDao(): ActivityPubLoggedAccountDao companion object { internal const val DB_NAME = "ActivityPubLoggedAccount.db" } } // The Room compiler generates the `actual` implementations. @Suppress("NO_ACTUAL_FOR_EXPECT") expect object ActivityPubLoggedAccountDatabaseConstructor : RoomDatabaseConstructor { override fun initialize(): ActivityPubLoggedAccountDatabase } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/ActivityPubPlatformTable.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db import androidx.room.Dao import androidx.room.Delete import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query import com.zhangke.activitypub.entities.ActivityPubInstanceEntity import com.zhangke.framework.network.FormalBaseUrl private const val TABLE_NAME = "platform_info" @Entity(tableName = TABLE_NAME) data class ActivityPubInstanceInfoEntity( @PrimaryKey val uri: String, val baseUrl: FormalBaseUrl, val instanceEntity: ActivityPubInstanceEntity, ) @Dao interface ActivityPubPlatformDao { @Query("SELECT * FROM $TABLE_NAME") suspend fun queryAll(): List @Query("SELECT * FROM $TABLE_NAME WHERE uri=:uri") suspend fun queryByUri(uri: String): ActivityPubInstanceInfoEntity? @Query("SELECT * FROM $TABLE_NAME WHERE baseUrl=:baseUrl") suspend fun queryByBaseUrl(baseUrl: FormalBaseUrl): ActivityPubInstanceInfoEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entity: ActivityPubInstanceInfoEntity) @Delete suspend fun deleteByUri(entity: ActivityPubInstanceInfoEntity) } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/UserIdTable.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db import androidx.room.Dao import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.utils.WebFinger private const val TABLE_NAME = "web_finger_baseurl_to_id" @Entity(tableName = TABLE_NAME, primaryKeys = ["webFinger", "baseUrl"]) data class WebFingerBaseurlToIdEntity( val webFinger: WebFinger, val baseUrl: FormalBaseUrl, val userId: String, ) @Dao interface WebFingerBaseurlToIdDao { @Query("SELECT userId FROM $TABLE_NAME WHERE webFinger = :webFinger AND baseUrl = :baseUrl") suspend fun queryUserId(webFinger: WebFinger, baseUrl: FormalBaseUrl): String? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entity: WebFingerBaseurlToIdEntity) @Query("DELETE FROM $TABLE_NAME WHERE webFinger = :webFinger AND baseUrl = :baseUrl") suspend fun delete(webFinger: WebFinger, baseUrl: String) } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/converter/ActivityPubAccountEntityConverter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db.converter import androidx.room.TypeConverter import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.framework.architect.json.globalJson import kotlinx.serialization.encodeToString class ActivityPubAccountEntityConverter { @TypeConverter fun toBlogAuthorString(blogAuthor: ActivityPubAccountEntity): String { return globalJson.encodeToString(blogAuthor) } @TypeConverter fun toBlogAuthor(jsonString: String): ActivityPubAccountEntity { return globalJson.decodeFromString(jsonString) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/converter/ActivityPubInstanceEntityConverter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db.converter import androidx.room.TypeConverter import com.zhangke.activitypub.entities.ActivityPubInstanceEntity import com.zhangke.framework.architect.json.globalJson import kotlinx.serialization.encodeToString class ActivityPubInstanceEntityConverter { @TypeConverter fun fromEntity(entity: ActivityPubInstanceEntity): String { return globalJson.encodeToString(entity) } @TypeConverter fun toEntity(text: String): ActivityPubInstanceEntity { return globalJson.decodeFromString(text) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/converter/ActivityPubLoggedAccountConverter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db.converter import androidx.room.TypeConverter import com.zhangke.framework.architect.json.globalJson import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount class ActivityPubLoggedAccountConverter { @TypeConverter fun toJsonString(blogAuthor: ActivityPubLoggedAccount): String { return globalJson.encodeToString(blogAuthor) } @TypeConverter fun toEntity(jsonString: String): ActivityPubLoggedAccount { return globalJson.decodeFromString(jsonString) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/converter/ActivityPubStatusEntityConverter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db.converter import androidx.room.TypeConverter import com.zhangke.activitypub.entities.ActivityPubStatusEntity import com.zhangke.framework.architect.json.globalJson import kotlinx.serialization.encodeToString class ActivityPubStatusEntityConverter { @TypeConverter fun fromEntity(entity: ActivityPubStatusEntity): String { return globalJson.encodeToString(entity) } @TypeConverter fun toEntity(text: String): ActivityPubStatusEntity { return globalJson.decodeFromString(text) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/converter/ActivityPubStatusSourceTypeConverter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db.converter import androidx.room.TypeConverter import com.zhangke.fread.activitypub.app.internal.model.ActivityPubStatusSourceType class ActivityPubStatusSourceTypeConverter { @TypeConverter fun fromType(type: ActivityPubStatusSourceType): String { return type.name } @TypeConverter fun toType(text: String): ActivityPubStatusSourceType { return ActivityPubStatusSourceType.valueOf(text) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/converter/ActivityPubUserTokenConverter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db.converter import androidx.room.TypeConverter import com.zhangke.activitypub.entities.ActivityPubTokenEntity import com.zhangke.framework.architect.json.globalJson import kotlinx.serialization.encodeToString class ActivityPubUserTokenConverter { @TypeConverter fun fromType(token: ActivityPubTokenEntity): String { return globalJson.encodeToString(token) } @TypeConverter fun toType(text: String): ActivityPubTokenEntity { return globalJson.decodeFromString(text) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/converter/BlogAuthorConverter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db.converter import androidx.room.TypeConverter import com.zhangke.framework.architect.json.globalJson import com.zhangke.fread.status.author.BlogAuthor import kotlinx.serialization.encodeToString class BlogAuthorConverter { @TypeConverter fun toBlogAuthorString(blogAuthor: BlogAuthor): String { return globalJson.encodeToString(blogAuthor) } @TypeConverter fun toBlogAuthor(jsonString: String): BlogAuthor { return globalJson.decodeFromString(jsonString) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/converter/EmojiListConverter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db.converter import androidx.room.TypeConverter import com.zhangke.framework.architect.json.globalJson import com.zhangke.fread.status.model.Emoji import kotlinx.serialization.encodeToString class EmojiListConverter { @TypeConverter fun toJsonString(blogAuthor: List): String { return globalJson.encodeToString(blogAuthor) } @TypeConverter fun toEmoji(jsonString: String): List { return globalJson.decodeFromString(jsonString) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/converter/FormalBaseUrlConverter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db.converter import androidx.room.TypeConverter import com.zhangke.framework.network.FormalBaseUrl class FormalBaseUrlConverter { @TypeConverter fun fromBaseUrl(baseUrl: FormalBaseUrl): String { return baseUrl.toString() } @TypeConverter fun toBaseUrl(text: String): FormalBaseUrl { return FormalBaseUrl.parse(text)!! } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/converter/PlatformEntityTypeConverter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db.converter import androidx.room.TypeConverter import com.zhangke.framework.architect.json.globalJson import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.db.old.OldActivityPubLoggedAccountEntity import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive class PlatformEntityTypeConverter { @TypeConverter fun fromType(platform: OldActivityPubLoggedAccountEntity.BlogPlatformEntity): String { return globalJson.encodeToString(platform) } @TypeConverter fun toType(text: String): OldActivityPubLoggedAccountEntity.BlogPlatformEntity { return runCatching { globalJson.decodeFromString(text) }.getOrNull() ?: compatibleObfuscation(text) } private fun compatibleObfuscation(text: String): OldActivityPubLoggedAccountEntity.BlogPlatformEntity { val jsonObject = runCatching { globalJson.decodeFromString(text) }.getOrNull() ?: JsonObject(emptyMap()) val baseUrlJson = jsonObject["d"]?.let { it as? JsonObject } val baseUrl = FormalBaseUrl.build( host = baseUrlJson.getValueAsString("host", "mastodon.social"), scheme = baseUrlJson.getValueAsString("scheme", "https"), ) val defaultUri = "freadapp://activitypub.com/platform?serverBaseUrl=https%3A%2F%2Fmastodon.social" return OldActivityPubLoggedAccountEntity.BlogPlatformEntity( uri = jsonObject.getValueAsString("a", defaultUri), name = jsonObject.getValueAsString("b", "mastodon.social"), description = jsonObject.getValueAsString("c", ""), baseUrl = baseUrl, thumbnail = jsonObject.getValueAsString("e", ""), ) } private fun JsonObject?.getValueAsString(key: String, default: String): String { this ?: return default val v = this[key] ?: return default if (v is JsonPrimitive) { return v.content } return v.toString() } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/converter/RelationshipSeveranceEventConverter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db.converter import androidx.room.TypeConverter import com.zhangke.framework.architect.json.globalJson import com.zhangke.fread.activitypub.app.internal.model.RelationshipSeveranceEvent import kotlinx.serialization.encodeToString class RelationshipSeveranceEventConverter { @TypeConverter fun fromType(platform: RelationshipSeveranceEvent?): String? { return platform?.let { globalJson.encodeToString(it) } } @TypeConverter fun toType(text: String?): RelationshipSeveranceEvent? { if (text.isNullOrEmpty()) return null return globalJson.decodeFromString(text) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/converter/StatusNotificationTypeConverter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db.converter import androidx.room.TypeConverter import com.zhangke.fread.activitypub.app.internal.model.StatusNotificationType class StatusNotificationTypeConverter { @TypeConverter fun convertToString(type: StatusNotificationType): String { return type.name } @TypeConverter fun convertToType(name: String): StatusNotificationType { return StatusNotificationType.valueOf(name) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/old/ActivityPubLoggedAccountTable.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db.old import androidx.room.Dao import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query import com.zhangke.activitypub.entities.ActivityPubTokenEntity import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.status.model.Emoji import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable private const val TABLE_NAME = "logged_accounts" @Entity(tableName = TABLE_NAME) data class OldActivityPubLoggedAccountEntity( @PrimaryKey val uri: String, val userId: String, val webFinger: WebFinger, val platform: BlogPlatformEntity, val baseUrl: FormalBaseUrl, val name: String, val description: String?, val avatar: String?, val url: String, val token: ActivityPubTokenEntity, val emojis: List, val addedTimestamp: Long, ) { @Serializable data class BlogPlatformEntity( val uri: String, val name: String, val description: String, val baseUrl: FormalBaseUrl, val thumbnail: String?, ) } @Dao interface OldActivityPubLoggerAccountDao { @Query("SELECT * FROM $TABLE_NAME ORDER BY addedTimestamp") fun queryAllFlow(): Flow> @Query("SELECT * FROM $TABLE_NAME WHERE uri=:uri") fun observeAccount(uri: String): Flow @Query("SELECT * FROM $TABLE_NAME ORDER BY addedTimestamp") suspend fun queryAll(): List @Query("SELECT * FROM $TABLE_NAME WHERE userId=:userId") suspend fun queryById(userId: String): OldActivityPubLoggedAccountEntity? @Query("SELECT * FROM $TABLE_NAME WHERE uri=:uri") suspend fun queryByUri(uri: String): OldActivityPubLoggedAccountEntity? @Query("SELECT * FROM $TABLE_NAME WHERE baseUrl=:baseUrl ORDER BY addedTimestamp") suspend fun queryByBaseUrl(baseUrl: FormalBaseUrl): List @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entry: OldActivityPubLoggedAccountEntity) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entries: List) @Query("DELETE FROM $TABLE_NAME WHERE uri=:uri") suspend fun deleteByUri(uri: String) @Query("DELETE FROM $TABLE_NAME") suspend fun nukeTable() } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/status/ActivityPubStatusDatabases.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db.status import androidx.room.ConstructedBy import androidx.room.Dao import androidx.room.Database import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.RoomDatabase import androidx.room.RoomDatabaseConstructor import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.SQLiteConnection import androidx.sqlite.execSQL import com.zhangke.fread.activitypub.app.internal.db.converter.ActivityPubStatusEntityConverter import com.zhangke.fread.activitypub.app.internal.db.converter.ActivityPubStatusSourceTypeConverter import com.zhangke.fread.activitypub.app.internal.db.converter.FormalBaseUrlConverter import com.zhangke.fread.activitypub.app.internal.model.ActivityPubStatusSourceType import com.zhangke.fread.common.db.converts.PlatformLocatorConverter import com.zhangke.fread.common.db.converts.StatusConverter import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.status.model.Status private const val DB_VERSION = 2 private const val TABLE_NAME = "activity_pub_status" @Entity(tableName = TABLE_NAME, primaryKeys = ["id", "locator", "type", "listId"]) data class ActivityPubStatusTableEntity( // Status id val id: String, val locator: PlatformLocator, val type: ActivityPubStatusSourceType, val listId: String, val status: Status, val createTimestamp: Long, ) @Dao interface ActivityPubStatusDao { @Query("SELECT * FROM $TABLE_NAME WHERE id = :id AND locator = :locator AND type = :type") suspend fun query( locator: PlatformLocator, type: ActivityPubStatusSourceType, id: String ): ActivityPubStatusTableEntity? @Query("SELECT * FROM $TABLE_NAME WHERE id = :id AND locator = :locator AND type = :type AND listId = :listId ORDER BY createTimestamp DESC") suspend fun queryStatusInList( locator: PlatformLocator, type: ActivityPubStatusSourceType, listId: String, id: String ): ActivityPubStatusTableEntity? @Query("SELECT * FROM $TABLE_NAME WHERE locator = :locator AND type = :type ORDER BY createTimestamp DESC LIMIT :limit") suspend fun queryTimelineStatus( locator: PlatformLocator, type: ActivityPubStatusSourceType, limit: Int, ): List @Query("SELECT * FROM $TABLE_NAME WHERE locator = :locator AND type = :type AND listId = :listId ORDER BY createTimestamp DESC LIMIT :limit") suspend fun queryListStatus( locator: PlatformLocator, type: ActivityPubStatusSourceType, listId: String, limit: Int ): List @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entity: ActivityPubStatusTableEntity) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entities: List) @Query("DELETE FROM $TABLE_NAME WHERE locator=:locator AND type=:type") suspend fun delete(locator: PlatformLocator, type: ActivityPubStatusSourceType) @Query("DELETE FROM $TABLE_NAME WHERE locator=:locator AND type=:type AND listId=:listId") suspend fun deleteListStatus( locator: PlatformLocator, type: ActivityPubStatusSourceType, listId: String ) @Query("DELETE FROM $TABLE_NAME WHERE id=:id") suspend fun delete(id: String) } @TypeConverters( FormalBaseUrlConverter::class, ActivityPubStatusSourceTypeConverter::class, ActivityPubStatusEntityConverter::class, StatusConverter::class, PlatformLocatorConverter::class, ) @Database( entities = [ActivityPubStatusTableEntity::class], version = DB_VERSION, exportSchema = false, ) @ConstructedBy(ActivityPubStatusDatabasesConstructor::class) abstract class ActivityPubStatusDatabases : RoomDatabase() { abstract fun getDao(): ActivityPubStatusDao companion object { const val DB_NAME = "activity_pub_status_1.db" val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(connection: SQLiteConnection) { connection.execSQL("DELETE FROM $TABLE_NAME") } } } } // The Room compiler generates the `actual` implementations. @Suppress("NO_ACTUAL_FOR_EXPECT") expect object ActivityPubStatusDatabasesConstructor : RoomDatabaseConstructor { override fun initialize(): ActivityPubStatusDatabases } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/db/status/ActivityPubStatusReadStateDatabases.kt ================================================ package com.zhangke.fread.activitypub.app.internal.db.status import androidx.room.ConstructedBy import androidx.room.Dao import androidx.room.Database import androidx.room.Delete import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.RoomDatabase import androidx.room.RoomDatabaseConstructor import androidx.room.TypeConverters import com.zhangke.fread.activitypub.app.internal.db.converter.ActivityPubStatusSourceTypeConverter import com.zhangke.fread.activitypub.app.internal.model.ActivityPubStatusSourceType import com.zhangke.fread.common.db.converts.PlatformLocatorConverter import com.zhangke.fread.status.model.PlatformLocator private const val DB_VERSION = 1 private const val TABLE_NAME = "activity_pub_status_read_state" @Entity(tableName = TABLE_NAME, primaryKeys = ["locator", "type", "listId"]) data class ActivityPubStatusReadStateEntity( val locator: PlatformLocator, val type: ActivityPubStatusSourceType, val listId: String, val latestReadId: String?, ) @Dao interface ActivityPubStatusReadStateDao { @Query("SELECT * FROM $TABLE_NAME WHERE locator = :locator AND type = :type") suspend fun query( locator: PlatformLocator, type: ActivityPubStatusSourceType, ): ActivityPubStatusReadStateEntity? @Query("SELECT * FROM $TABLE_NAME WHERE locator = :locator AND type = :type AND listId = :listId") suspend fun queryList( locator: PlatformLocator, type: ActivityPubStatusSourceType, listId: String, ): ActivityPubStatusReadStateEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun update(entity: ActivityPubStatusReadStateEntity) @Delete suspend fun delete(entity: ActivityPubStatusReadStateEntity) } @TypeConverters( ActivityPubStatusSourceTypeConverter::class, PlatformLocatorConverter::class, ) @Database( entities = [ActivityPubStatusReadStateEntity::class], version = DB_VERSION, exportSchema = false, ) @ConstructedBy(ActivityPubStatusReadStateDatabasesConstructor::class) abstract class ActivityPubStatusReadStateDatabases : RoomDatabase() { abstract fun getDao(): ActivityPubStatusReadStateDao companion object { internal const val DB_NAME = "activity_pub_status_read_state_1.db" } } // The Room compiler generates the `actual` implementations. @Suppress("NO_ACTUAL_FOR_EXPECT") expect object ActivityPubStatusReadStateDatabasesConstructor : RoomDatabaseConstructor { override fun initialize(): ActivityPubStatusReadStateDatabases } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/migrate/ActivityPubContentMigrator.kt ================================================ package com.zhangke.fread.activitypub.app.internal.migrate import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.activitypub.app.internal.repo.account.ActivityPubLoggedAccountRepo import com.zhangke.fread.common.content.FreadContentRepo class ActivityPubContentMigrator ( private val freadContentRepo: FreadContentRepo, private val accountRepo: ActivityPubLoggedAccountRepo, ) { suspend fun migrate() { val allContent = freadContentRepo.getAllOldContents().filter { it.second is ActivityPubContent } if (allContent.isEmpty()) return val allAccounts = accountRepo.queryAll() allContent.map { it.second as ActivityPubContent } .map { content -> if (content.accountUri != null) { content } else { val account = allAccounts.firstOrNull { it.baseUrl == content.baseUrl } content.copy(accountUri = account?.uri) } }.let { freadContentRepo.insertAll(it) } for (content in allContent) { freadContentRepo.deleteOldContents(content.first) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/model/ActivityPubApplication.kt ================================================ package com.zhangke.fread.activitypub.app.internal.model import com.zhangke.framework.network.FormalBaseUrl data class ActivityPubApplication( val baseUrl: FormalBaseUrl, val id: String, val name: String, val website: String, val redirectUri: String, val clientId: String, val clientSecret: String, val vapidKey: String, ) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/model/ActivityPubInstanceRule.kt ================================================ package com.zhangke.fread.activitypub.app.internal.model data class ActivityPubInstanceRule( val id: String, val text: String, ) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/model/ActivityPubLoggedAccount.kt ================================================ package com.zhangke.fread.activitypub.app.internal.model import com.zhangke.activitypub.entities.ActivityPubTokenEntity import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.Emoji import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.uri.FormalUri import kotlinx.serialization.Serializable @Serializable data class ActivityPubLoggedAccount( val userId: String, val baseUrl: FormalBaseUrl, val url: String, val token: ActivityPubTokenEntity, val banner: String, val followersCount: Long, val followingCount: Long, val statusesCount: Long, val note: String, val bot: Boolean, override val uri: FormalUri, override val webFinger: WebFinger, override val platform: BlogPlatform, override val userName: String, override val description: String?, override val avatar: String?, override val emojis: List, ) : LoggedAccount { override val id: String? get() = userId override val prettyHandle: String get() { val handle = webFinger.toString() return if (handle.startsWith('@')) handle else "@$handle" } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/model/ActivityPubStatusSourceType.kt ================================================ package com.zhangke.fread.activitypub.app.internal.model enum class ActivityPubStatusSourceType { TIMELINE_HOME, TIMELINE_LOCAL, TIMELINE_PUBLIC, LIST, } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/model/ActivityPubTimelineType.kt ================================================ package com.zhangke.fread.activitypub.app.internal.model import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.getString enum class ActivityPubTimelineType { PUBLIC, LOCAL, HOME; suspend fun nickName(): String { return when (this) { HOME -> getString(LocalizedString.activity_pub_home_timeline) LOCAL -> getString(LocalizedString.activity_pub_local_timeline) PUBLIC -> getString(LocalizedString.activity_pub_public_timeline) } } companion object { fun valurOfOrNull(name: String): ActivityPubTimelineType? { return entries.firstOrNull { it.name == name } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/model/CustomEmoji.kt ================================================ package com.zhangke.fread.activitypub.app.internal.model data class CustomEmoji( val shortcode: String, val url: String, val staticUrl: String, val visibleInPicker: Boolean, val category: String, ) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/model/PlatformUriInsights.kt ================================================ package com.zhangke.fread.activitypub.app.internal.model import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.status.uri.FormalUri data class PlatformUriInsights( val uri: FormalUri, val serverBaseUrl: FormalBaseUrl, ) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/model/RelationshipSeveranceEvent.kt ================================================ package com.zhangke.fread.activitypub.app.internal.model import com.zhangke.framework.datetime.Instant import kotlinx.serialization.Serializable @Serializable data class RelationshipSeveranceEvent( val id: String, val type: Type, val purged: Boolean, val targetName: String, val relationshipsCount: Int?, val createdAt: Instant, ) { enum class Type { DOMAIN_BLOCK, USER_DOMAIN_BLOCK, ACCOUNT_SUSPENSION, } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/model/ServerDetailContract.kt ================================================ package com.zhangke.fread.activitypub.app.internal.model import com.zhangke.fread.status.author.BlogAuthor data class ServerDetailContract( val email: String, val account: BlogAuthor, ) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/model/StatusNotification.kt ================================================ @file:OptIn(ExperimentalTime::class) package com.zhangke.fread.activitypub.app.internal.model import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.fread.status.status.model.Status import kotlinx.datetime.Instant import kotlin.time.ExperimentalTime data class StatusNotification( val id: String, val type: StatusNotificationType, val createdAt: Instant, /** * The account that performed the action that generated the notification. */ val account: ActivityPubAccountEntity, val status: Status?, val relationshipSeveranceEvent: RelationshipSeveranceEvent?, ) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/model/StatusNotificationType.kt ================================================ package com.zhangke.fread.activitypub.app.internal.model enum class StatusNotificationType { MENTION, STATUS, REBLOG, FOLLOW, FOLLOW_REQUEST, FAVOURITE, /** * A poll you have voted in or created has ended */ POLL, /** * A status you boosted with has been edited */ UPDATE, SEVERED_RELATIONSHIPS, UNKNOWN; } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/model/UserSource.kt ================================================ package com.zhangke.fread.activitypub.app.internal.model //data class UserSource( // val uri: StatusProviderUri, // val userId: String, // val webFinger: WebFinger, // val name: String, // val description: String, // val thumbnail: String?, //) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/model/UserUriInsights.kt ================================================ package com.zhangke.fread.activitypub.app.internal.model import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.utils.Parcelize import com.zhangke.framework.utils.PlatformParcelable import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.status.uri.FormalUri import kotlinx.serialization.Serializable @Parcelize @Serializable data class UserUriInsights( val uri: FormalUri, val webFinger: WebFinger, val baseUrl: FormalBaseUrl, ) : PlatformParcelable ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/platform/FreadApplicationRegisterInfo.kt ================================================ package com.zhangke.fread.activitypub.app.internal.platform import com.zhangke.activitypub.api.AppsRepo import com.zhangke.fread.common.config.AppCommonConfig object FreadApplicationRegisterInfo { const val CLIENT_NAME = "Fread" val redirectUris = listOf("freadapp://fread.xyz") val scopes = AppsRepo.AppScopes.ALL const val WEBSITE = AppCommonConfig.WEBSITE } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/push/ActivityPubPushManager.kt ================================================ package com.zhangke.fread.activitypub.app.internal.push import com.zhangke.fread.status.model.PlatformLocator expect class ActivityPubPushManager { suspend fun subscribe(locator: PlatformLocator, accountId: String) suspend fun unsubscribe(locator: PlatformLocator, accountId: String) } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/push/ActivityPubPushMessageReceiver.kt ================================================ package com.zhangke.fread.activitypub.app.internal.push import com.zhangke.fread.common.push.PushMessage import com.zhangke.fread.common.push.PushMessageReceiver import com.zhangke.krouter.annotation.Service expect class ActivityPubPushMessageReceiverHelper() { fun onReceiveNewMessage(message: PushMessage) } @Service class ActivityPubPushMessageReceiver : PushMessageReceiver { private val pushMessageReceiverHelper = ActivityPubPushMessageReceiverHelper() override fun onReceiveNewMessage(message: PushMessage) { pushMessageReceiverHelper.onReceiveNewMessage(message) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/repo/WebFingerBaseUrlToUserIdRepo.kt ================================================ package com.zhangke.fread.activitypub.app.internal.repo import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.db.ActivityPubDatabases import com.zhangke.fread.activitypub.app.internal.db.WebFingerBaseurlToIdEntity import com.zhangke.fread.status.model.PlatformLocator class WebFingerBaseUrlToUserIdRepo ( activityPubDatabases: ActivityPubDatabases, private val clientManager: ActivityPubClientManager, ) { private val userIdDao = activityPubDatabases.getUserIdDao() suspend fun getUserId(webFinger: WebFinger, locator: PlatformLocator): Result { val baseUrl = locator.baseUrl val userIdFromLocal = userIdDao.queryUserId(webFinger, baseUrl) if (userIdFromLocal.isNullOrEmpty().not()) return Result.success(userIdFromLocal!!) val result = lookupAccount(webFinger, locator) if (result.isFailure) return Result.failure(result.exceptionOrNull()!!) val account = result.getOrNull() ?: return Result.failure(IllegalArgumentException("Can't find $webFinger in $baseUrl")) insert(webFinger, locator, account.id) return Result.success(account.id) } private suspend fun lookupAccount( webFinger: WebFinger, locator: PlatformLocator, ): Result { // 先通过 WebFinger 查找,找不到则通过 name 查找。 // 例如这个用户:https://m.cmx.im/@b0rk@jvns.ca // 目前我们的客户端创建的 WebFinger 为:@b0rk@social.jvns.ca // 因为我们的客户端的 WebFinger 中的 host 是实际上真正的 host。 // 但这个用户在自己服务器上的 WebFinger 是 @b0rk@jvns.ca, // 直接使用我们的 WebFinger 是查找不到用户的,但是通过用户名可以查找到。 // 类似的,JW 大佬的账号也是这样,这是个普遍情况。 val accountRepo = clientManager.getClient(locator).accountRepo val result = accountRepo.lookup(webFinger.toString()) if (result.isSuccess) return result return accountRepo.lookup(webFinger.name) } suspend fun insert(webFinger: WebFinger, locator: PlatformLocator, userId: String) { userIdDao.insert(WebFingerBaseurlToIdEntity(webFinger, locator.baseUrl, userId)) } suspend fun delete(webFinger: WebFinger, locator: PlatformLocator) { userIdDao.delete(webFinger, locator.baseUrl.toString()) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/repo/account/ActivityPubLoggedAccountRepo.kt ================================================ package com.zhangke.fread.activitypub.app.internal.repo.account import com.zhangke.fread.activitypub.app.internal.db.ActivityPubDatabases import com.zhangke.fread.activitypub.app.internal.db.ActivityPubLoggedAccountDao import com.zhangke.fread.activitypub.app.internal.db.ActivityPubLoggedAccountDatabase import com.zhangke.fread.activitypub.app.internal.db.ActivityPubLoggedAccountEntity import com.zhangke.fread.activitypub.app.internal.db.old.OldActivityPubLoggerAccountDao import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.common.utils.getCurrentTimeMillis import com.zhangke.fread.status.uri.FormalUri import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.map class ActivityPubLoggedAccountRepo ( private val oldDatabases: ActivityPubDatabases, private val accountDatabase: ActivityPubLoggedAccountDatabase, ) { private val _onNewAccountFlow = MutableSharedFlow() val onNewAccountFlow: Flow = _onNewAccountFlow private val oldAccountDao: OldActivityPubLoggerAccountDao get() = oldDatabases.getLoggedAccountDao() private val accountDao: ActivityPubLoggedAccountDao get() = accountDatabase.getDao() suspend fun initialize() { LoggedAccountMigrateUtil.migrate( oldDao = oldAccountDao, accountDao = accountDao, ) } fun getAllAccountFlow(): Flow> { return accountDao.queryAllFlow().map { list -> list.map { it.account } } } fun observeAccount(uri: String): Flow { return accountDao.observeAccount(uri) .map { it?.account } } suspend fun queryAll(): List = accountDao.queryAll().map { it.account } suspend fun queryByUri(uri: String): ActivityPubLoggedAccount? = accountDao.queryByUri(uri)?.account suspend fun insert( account: ActivityPubLoggedAccount, addedTimestamp: Long, ) { val entity = buildAccountEntity(account, addedTimestamp) accountDao.insert(entity) _onNewAccountFlow.emit(account) } suspend fun update(account: ActivityPubLoggedAccount) { val addedTimestamp = accountDao.queryByUri(account.uri.toString())?.addedTimestamp ?: getCurrentTimeMillis() val entity = buildAccountEntity(account, addedTimestamp) accountDao.insert(entity) } private fun buildAccountEntity( account: ActivityPubLoggedAccount, addedTimestamp: Long, ): ActivityPubLoggedAccountEntity { return ActivityPubLoggedAccountEntity( uri = account.uri.toString(), account = account, addedTimestamp = addedTimestamp, ) } suspend fun deleteByUri(uri: FormalUri) { accountDao.deleteByUri(uri.toString()) oldAccountDao.deleteByUri(uri.toString()) } suspend fun clear() = accountDao.nukeTable() } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/repo/account/LoggedAccountMigrateUtil.kt ================================================ package com.zhangke.fread.activitypub.app.internal.repo.account import com.zhangke.fread.status.model.createActivityPubProtocol import com.zhangke.fread.activitypub.app.internal.db.ActivityPubLoggedAccountDao import com.zhangke.fread.activitypub.app.internal.db.ActivityPubLoggedAccountEntity import com.zhangke.fread.activitypub.app.internal.db.old.OldActivityPubLoggedAccountEntity import com.zhangke.fread.activitypub.app.internal.db.old.OldActivityPubLoggerAccountDao import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.uri.FormalUri object LoggedAccountMigrateUtil { suspend fun migrate( oldDao: OldActivityPubLoggerAccountDao, accountDao: ActivityPubLoggedAccountDao, ) { val oldAccountList = oldDao.queryAll() .map { convertToLoggedAccount(it) } if (oldAccountList.isEmpty()) return accountDao.insert(oldAccountList) oldDao.nukeTable() } private suspend fun convertToLoggedAccount( entity: OldActivityPubLoggedAccountEntity, ): ActivityPubLoggedAccountEntity { val account = ActivityPubLoggedAccount( userId = entity.userId, uri = FormalUri.from(entity.uri)!!, webFinger = entity.webFinger, platform = entity.platform.toPlatform(), baseUrl = entity.baseUrl, userName = entity.name, description = entity.description, avatar = entity.avatar, url = entity.url, token = entity.token, emojis = entity.emojis, followersCount = 0L, followingCount = 0L, statusesCount = 0L, banner = "", note = "", bot = false, ) return ActivityPubLoggedAccountEntity( uri = account.uri.toString(), account = account, addedTimestamp = entity.addedTimestamp, ) } private fun OldActivityPubLoggedAccountEntity.BlogPlatformEntity.toPlatform(): BlogPlatform = BlogPlatform( uri = uri, name = name, description = description, baseUrl = baseUrl, thumbnail = thumbnail, protocol = createActivityPubProtocol(), ) } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/repo/application/ActivityPubApplicationRepo.kt ================================================ package com.zhangke.fread.activitypub.app.internal.repo.application import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubApplicationEntityAdapter import com.zhangke.fread.activitypub.app.internal.adapter.RegisterApplicationEntryAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.db.ActivityPubApplicationsDao import com.zhangke.fread.activitypub.app.internal.db.ActivityPubDatabases import com.zhangke.fread.activitypub.app.internal.model.ActivityPubApplication import com.zhangke.fread.activitypub.app.internal.platform.FreadApplicationRegisterInfo import com.zhangke.fread.status.model.PlatformLocator class ActivityPubApplicationRepo ( private val databases: ActivityPubDatabases, private val clientManager: ActivityPubClientManager, private val registerApplicationEntryAdapter: RegisterApplicationEntryAdapter, private val applicationEntityAdapter: ActivityPubApplicationEntityAdapter, ) { private val applicationsDao: ActivityPubApplicationsDao get() = databases.getApplicationDao() suspend fun getApplicationByBaseUrl(baseUrl: FormalBaseUrl): ActivityPubApplication? { applicationsDao.queryByBaseUrl(baseUrl) ?.let(applicationEntityAdapter::toApplication) ?.let { return it } val locator = PlatformLocator(accountUri = null, baseUrl = baseUrl) val application = clientManager.getClient(locator) .appsRepo .registerApplication( clientName = FreadApplicationRegisterInfo.CLIENT_NAME, redirectUris = FreadApplicationRegisterInfo.redirectUris, scopes = FreadApplicationRegisterInfo.scopes, website = FreadApplicationRegisterInfo.WEBSITE, ).map { registerApplicationEntryAdapter.toApplication(it, baseUrl) } .getOrNull() ?: return null insert(application) return application } suspend fun insert(application: ActivityPubApplication) { applicationsDao.insert(applicationEntityAdapter.toEntity(application)) } suspend fun delete(application: ActivityPubApplication) { applicationsDao.delete(applicationEntityAdapter.toEntity(application)) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/repo/platform/ActivityPubPlatformRepo.kt ================================================ package com.zhangke.fread.activitypub.app.internal.repo.platform import com.zhangke.activitypub.entities.ActivityPubInstanceEntity import com.zhangke.framework.architect.coroutines.ApplicationScope import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubInstanceAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubPlatformEntityAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.db.ActivityPubDatabases import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.platform.PlatformSnapshot import kotlinx.coroutines.launch class ActivityPubPlatformRepo ( databases: ActivityPubDatabases, private val clientManager: ActivityPubClientManager, private val activityPubPlatformEntityAdapter: ActivityPubPlatformEntityAdapter, private val activityPubInstanceAdapter: ActivityPubInstanceAdapter, private val platformResourceLoader: BlogPlatformResourceLoader, private val mastodonInstanceRepo: MastodonInstanceRepo, private val loggedAccountProvider: LoggedAccountProvider, ) { private val localPlatformSnapshotList = mutableListOf() private val platformDao = databases.getPlatformDao() suspend fun getPlatform(locator: PlatformLocator): Result { return getPlatform(locator.baseUrl) } suspend fun getPlatform(baseUrl: FormalBaseUrl): Result { return getInstanceInfo(baseUrl).map { activityPubInstanceAdapter.toPlatform(baseUrl, it) } } suspend fun getInstanceEntity(baseUrl: FormalBaseUrl): Result { return getInstanceInfo(baseUrl) } suspend fun getSuggestedPlatformSnapshotList(): List { return getAllLocalPlatformSnapshot() } suspend fun searchPlatformSnapshotFromLocal(query: String): List { val localPlatforms = getAllLocalPlatformSnapshot() return localPlatforms.filter { it.domain.contains(query, true) || it.description.contains(query, true) }.distinctBy { it.domain } } suspend fun searchPlatformFromServer(query: String): Result> { return mastodonInstanceRepo.searchWithName(query) } private suspend fun getAllLocalPlatformSnapshot(): List { if (localPlatformSnapshotList.isEmpty()) { localPlatformSnapshotList += platformResourceLoader.loadLocalPlatforms() } return localPlatformSnapshotList } private suspend fun getInstanceInfo(baseUrl: FormalBaseUrl): Result { val instanceFromLocal = platformDao.queryByBaseUrl(baseUrl) val locator = PlatformLocator(accountUri = null, baseUrl = baseUrl) if (instanceFromLocal != null) { if (instanceFromLocal.instanceEntity.apiVersions == null) { // refresh local data val accountUri = loggedAccountProvider.getAllAccounts() .firstOrNull { it.baseUrl == baseUrl } ?.uri val fixedLocator = PlatformLocator(accountUri = accountUri, baseUrl = baseUrl) ApplicationScope.launch { clientManager.getClient(fixedLocator) .instanceRepo .getInstanceInformation() .map { activityPubPlatformEntityAdapter.toEntity(baseUrl, it) } .onSuccess { platformDao.insert(it) } } } return Result.success(instanceFromLocal.instanceEntity) } val instanceResult = clientManager.getClient(locator).instanceRepo.getInstanceInformation() if (instanceResult.isFailure) { return Result.failure(instanceResult.exceptionOrNull()!!) } val instanceEntity = instanceResult.getOrThrow() platformDao.insert(activityPubPlatformEntityAdapter.toEntity(baseUrl, instanceEntity)) return Result.success(instanceEntity) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/repo/platform/BlogPlatformResourceLoader.kt ================================================ package com.zhangke.fread.activitypub.app.internal.repo.platform import com.zhangke.framework.architect.json.globalJson import com.zhangke.fread.status.model.createActivityPubProtocol import com.zhangke.fread.activitypub.app.internal.utils.MastodonHelper import com.zhangke.fread.status.platform.PlatformSnapshot import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.intOrNull class BlogPlatformResourceLoader ( private val mastodonHelper: MastodonHelper, ) { suspend fun loadLocalPlatforms(): List = withContext(Dispatchers.IO) { val json = mastodonHelper.getLocalMastodonJson() if (json.isNullOrEmpty()) return@withContext emptyList() return@withContext globalJson.decodeFromString(json) ?.mapNotNull { it as? JsonObject } ?.mapNotNull { it.toPlatformSnapshot() } ?.distinctBy { it.domain } ?: emptyList() } private fun JsonObject.toPlatformSnapshot(): PlatformSnapshot? { val domain = getAsString("domain") ?: return null return PlatformSnapshot( domain = domain.lowercase(), description = getAsString("description").orEmpty(), thumbnail = getAsString("proxied_thumbnail").orEmpty(), protocol = createActivityPubProtocol(), ) } private fun JsonObject.getAsString(key: String): String? { val element = get(key) if (element is JsonPrimitive) { return element.contentOrNull } return null } private fun JsonObject.getAsInt(key: String): Int? { val element = get(key) if (element is JsonPrimitive) { return element.intOrNull } return null } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/repo/platform/MastodonInstanceRepo.kt ================================================ package com.zhangke.fread.activitypub.app.internal.repo.platform import com.zhangke.framework.architect.http.sharedHttpClient import com.zhangke.fread.status.model.createActivityPubProtocol import com.zhangke.fread.status.platform.PlatformSnapshot import io.ktor.client.call.body import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter import io.ktor.client.request.url import io.ktor.http.takeFrom import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable class MastodonInstanceRepo () { private companion object { private const val BASE_URL = "https://instances.social/api/1.0" private const val PATH_SEARCH = "$BASE_URL/instances/search" private const val TOKEN = "kw1opiSJ24Gwddb49rRCbOZlFrI6cCxSxprWhsGMDzKQA36obwpcFr9DldvIwUDAXvbprvwCEUCUZ4eKQYr1vxZ8QP5PkD3dGUKlaETF4MAMsKJXEFiY5gKfzYL8MoUE" } suspend fun searchAll(query: String): Result> { return runCatching { sharedHttpClient.get { url(PATH_SEARCH) { parameter("q", query) parameter("name", false) parameter("count", 120) } applyCommonHeaders() }.body() .instances .map { it.toPlatformSnapshot() } } } suspend fun searchWithName(name: String): Result> { return runCatching { sharedHttpClient.get { url { takeFrom(PATH_SEARCH) parameter("q", name) parameter("name", true) parameter("count", 20) } applyCommonHeaders() }.body() .instances .map { it.toPlatformSnapshot() } } } private fun HttpRequestBuilder.applyCommonHeaders() { header("Authorization", "Bearer $TOKEN") } private suspend fun MastodonInstance.toPlatformSnapshot(): PlatformSnapshot { return PlatformSnapshot( domain = name, description = info?.shortDescription.orEmpty(), thumbnail = thumbnail.orEmpty(), protocol = createActivityPubProtocol(), ) } @Serializable data class QueryResult( val instances: List, ) @Serializable data class MastodonInstance( val name: String, val info: Info?, val thumbnail: String?, val version: String?, val users: Int?, @SerialName("active_users") val activeUsers: Int?, ) { @Serializable data class Info( @SerialName("short_description") val shortDescription: String?, val languages: List?, val categories: List?, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/repo/status/ActivityPubStatusReadStateRepo.kt ================================================ package com.zhangke.fread.activitypub.app.internal.repo.status import com.zhangke.fread.activitypub.app.internal.db.status.ActivityPubStatusReadStateDatabases import com.zhangke.fread.activitypub.app.internal.db.status.ActivityPubStatusReadStateEntity import com.zhangke.fread.activitypub.app.internal.model.ActivityPubStatusSourceType import com.zhangke.fread.status.model.PlatformLocator class ActivityPubStatusReadStateRepo ( activityPubStatusReadStateDatabases: ActivityPubStatusReadStateDatabases, ) { private val readStateDao = activityPubStatusReadStateDatabases.getDao() suspend fun getLatestReadId( locator: PlatformLocator, type: ActivityPubStatusSourceType, listId: String? = null ): String? { return if (listId == null) { readStateDao.query( locator = locator, type = type, )?.latestReadId } else { readStateDao.queryList( locator = locator, type = type, listId = listId, )?.latestReadId } } suspend fun updateLatestReadId( locator: PlatformLocator, type: ActivityPubStatusSourceType, listId: String? = null, latestReadId: String ) { val entity = ActivityPubStatusReadStateEntity( locator = locator, type = type, listId = listId.orEmpty(), latestReadId = latestReadId, ) readStateDao.update(entity) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/repo/status/ActivityPubTimelineStatusRepo.kt ================================================ package com.zhangke.fread.activitypub.app.internal.repo.status import com.zhangke.fread.activitypub.app.internal.db.status.ActivityPubStatusDatabases import com.zhangke.fread.activitypub.app.internal.db.status.ActivityPubStatusTableEntity import com.zhangke.fread.activitypub.app.internal.model.ActivityPubStatusSourceType import com.zhangke.fread.activitypub.app.internal.usecase.status.GetTimelineStatusUseCase import com.zhangke.fread.common.status.StatusConfigurationDefault import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.status.model.Status class ActivityPubTimelineStatusRepo ( activityPubStatusDatabases: ActivityPubStatusDatabases, private val getTimeline: GetTimelineStatusUseCase, ) { private val statusDao = activityPubStatusDatabases.getDao() private val statusConfig = StatusConfigurationDefault.config /** * 获取最新的帖子列表 */ suspend fun getFresherStatus( locator: PlatformLocator, type: ActivityPubStatusSourceType, listId: String? = null, limit: Int = statusConfig.loadFromServerLimit, ): Result> { return getTimeline( locator = locator, type = type, limit = limit, listId = listId, maxId = null, minId = null, ).onSuccess { saveFresherStatus( locator = locator, type = type, listId = listId, statusList = it, ) } } private suspend fun saveFresherStatus( locator: PlatformLocator, type: ActivityPubStatusSourceType, listId: String?, statusList: List, ) { if (statusList.isEmpty()) return val earlierStatus = statusList.minBy { it.createAt.epochMillis } val localEarlierStatus = queryLocalStatus( locator = locator, type = type, listId = listId, statusId = earlierStatus.id, ) if (localEarlierStatus == null) { // local data are expired deleteStatus(locator, type, listId) } statusList.insertToLocal(locator, type, listId) } suspend fun loadPreviousPageStatus( locator: PlatformLocator, type: ActivityPubStatusSourceType, minId: String, listId: String? = null, limit: Int = statusConfig.loadFromServerLimit, ): Result> { return getTimeline( locator = locator, type = type, limit = limit, maxId = null, minId = minId, listId = listId, ).onSuccess { it.insertToLocal(locator, type, listId) } } suspend fun getStatusFromLocal( locator: PlatformLocator, type: ActivityPubStatusSourceType, limit: Int = statusConfig.loadFromLocalLimit, listId: String? = null, ): List { return queryLocalStatusList( locator = locator, type = type, limit = limit, listId = listId, ).map { it.status } } suspend fun loadMore( locator: PlatformLocator, type: ActivityPubStatusSourceType, maxId: String, listId: String? = null, limit: Int = statusConfig.loadFromLocalLimit, ): Result> { return getTimeline( locator = locator, type = type, limit = limit, maxId = maxId, minId = null, listId = listId, ).onSuccess { it.insertToLocal(locator, type, listId) } } suspend fun updateStatus( locator: PlatformLocator, type: ActivityPubStatusSourceType, status: Status, listId: String? = null, ) { val localStatus = queryLocalStatus( locator = locator, type = type, listId = listId, statusId = status.id, ) if (localStatus != null) { statusDao.insert(status.toDBEntity(locator, type, listId)) } val leftTypes = ActivityPubStatusSourceType.entries.filter { it != type } leftTypes.mapNotNull { queryLocalStatus( locator = locator, type = it, listId = listId, statusId = status.id, ) }.forEach { statusDao.insert(status.toDBEntity(locator, it.type, it.listId)) } } suspend fun deleteStatus(statusId: String) { statusDao.delete(statusId) } private suspend fun List.insertToLocal( locator: PlatformLocator, type: ActivityPubStatusSourceType, listId: String?, ) { statusDao.insert(this.toDBEntities(locator, type, listId)) } private suspend fun queryLocalStatus( locator: PlatformLocator, type: ActivityPubStatusSourceType, listId: String?, statusId: String, ): ActivityPubStatusTableEntity? { return if (type == ActivityPubStatusSourceType.LIST && listId != null) { statusDao.queryStatusInList( locator = locator, type = type, listId = listId, id = statusId, ) } else { statusDao.query(locator, type, statusId) } } private suspend fun queryLocalStatusList( locator: PlatformLocator, type: ActivityPubStatusSourceType, limit: Int, listId: String?, ): List { return if (type == ActivityPubStatusSourceType.LIST) { statusDao.queryListStatus( locator = locator, type = type, limit = limit, listId = listId!!, ) } else { statusDao.queryTimelineStatus(locator, type, limit) } } private suspend fun deleteStatus( locator: PlatformLocator, type: ActivityPubStatusSourceType, listId: String?, ) { if (listId.isNullOrEmpty()) { statusDao.delete(locator, type) } else { statusDao.deleteListStatus(locator, type, listId) } } private fun List.toDBEntities( locator: PlatformLocator, type: ActivityPubStatusSourceType, listId: String?, ): List { return this.map { it.toDBEntity( locator = locator, type = type, listId = listId, ) } } private fun Status.toDBEntity( locator: PlatformLocator, type: ActivityPubStatusSourceType, listId: String?, ): ActivityPubStatusTableEntity { return ActivityPubStatusTableEntity( id = this.id, locator = locator, type = type, createTimestamp = this.createAt.epochMillis, listId = listId.orEmpty(), status = this, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/repo/user/UserRepo.kt ================================================ package com.zhangke.fread.activitypub.app.internal.repo.user import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.model.UserUriInsights import com.zhangke.fread.activitypub.app.internal.repo.WebFingerBaseUrlToUserIdRepo import com.zhangke.fread.activitypub.app.internal.source.UserSourceTransformer import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.source.StatusSource class UserRepo ( private val clientManager: ActivityPubClientManager, private val webFingerBaseUrlToUserIdRepo: WebFingerBaseUrlToUserIdRepo, private val userSourceTransformer: UserSourceTransformer, ) { suspend fun getUserSource( locator: PlatformLocator, userUriInsights: UserUriInsights, ): Result { val userIdResult = webFingerBaseUrlToUserIdRepo.getUserId(userUriInsights.webFinger, locator) if (userIdResult.isFailure) return Result.failure(userIdResult.exceptionOrNull()!!) val userId = userIdResult.getOrThrow() return clientManager.getClient(locator) .accountRepo .getAccount(userId) .map { userSourceTransformer.createByUserEntity(it) } } suspend fun lookupUserSource( locator: PlatformLocator, acct: String, ): Result { return clientManager.getClient(locator).accountRepo .lookup(acct) .onSuccess { if (it != null) { val webFinger = WebFinger.create(it.acct) if (webFinger != null) { webFingerBaseUrlToUserIdRepo.insert(webFinger, locator, it.id) } } } .map { it?.let { userSourceTransformer.createByUserEntity(it) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/route/ActivityPubRoutes.kt ================================================ package com.zhangke.fread.activitypub.app.internal.route import com.zhangke.framework.network.GlobalRoutes object ActivityPubRoutes { const val ROOT = "${GlobalRoutes.ROOT_PREFIX}activitypub" } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/account/EditAccountInfoScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.account import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.seiko.imageloader.ui.AutoSizeImage import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.IconButtonStyle import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.StyledIconButton import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.freadPlaceholder import com.zhangke.framework.composable.pick.PickVisualMediaLauncherContainer import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.utils.PlatformUri import com.zhangke.fread.localization.LocalizedString import kotlinx.coroutines.flow.SharedFlow import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable data class EditAccountInfoScreenNavKey( val baseUrl: String, val accountUri: String, ) : NavKey @Composable fun EditAccountInfoScreen(viewModel: EditAccountInfoViewModel) { val backStack = LocalNavBackStack.currentOrThrow val uiState by viewModel.uiState.collectAsState() EditAccountInfoContent( uiState = uiState, snackBarMessageFlow = viewModel.snackBarMessageFlow, onBackClick = backStack::removeLastOrNull, onEditClick = viewModel::onEditClick, onUserNameChanged = viewModel::onUserNameInput, onBioChanged = viewModel::onUserDescriptionInput, onFieldChanged = viewModel::onFieldInput, onFieldDeleteClick = viewModel::onFieldDelete, onFieldAddClick = viewModel::onFieldAddClick, onAvatarSelected = viewModel::onAvatarSelected, onHeaderSelected = viewModel::onHeaderSelected, ) ConsumeFlow(viewModel.finishPageFlow) { backStack.removeLastOrNull() } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun EditAccountInfoContent( uiState: EditAccountUiState, snackBarMessageFlow: SharedFlow, onBackClick: () -> Unit, onEditClick: () -> Unit, onUserNameChanged: (String) -> Unit, onBioChanged: (String) -> Unit, onFieldChanged: (Int, String, String) -> Unit, onFieldDeleteClick: (Int) -> Unit, onFieldAddClick: () -> Unit, onAvatarSelected: (PlatformUri) -> Unit, onHeaderSelected: (PlatformUri) -> Unit, ) { val snackBarHost = rememberSnackbarHostState() ConsumeSnackbarFlow(snackBarHost, snackBarMessageFlow) Scaffold( snackbarHost = { SnackbarHost(snackBarHost) }, topBar = { TopAppBar( navigationIcon = { SimpleIconButton( onClick = onBackClick, imageVector = Icons.Default.Close, contentDescription = "Close", ) }, title = { Text(text = uiState.name) }, actions = { if (uiState.requesting) { CircularProgressIndicator( modifier = Modifier .padding(end = 8.dp) .size(24.dp) ) } else { SimpleIconButton( onClick = onEditClick, imageVector = Icons.Default.Check, contentDescription = "Edit", ) } } ) } ) { paddingValues -> Column( modifier = Modifier .padding(paddingValues) .padding(bottom = 30.dp) .verticalScroll(rememberScrollState()) .imePadding(), ) { val headerHeight = 150.dp val avatarSize = 80.dp Box( modifier = Modifier .fillMaxWidth() .height(headerHeight + avatarSize / 2) ) { HeaderInEdit( uiState = uiState, headerHeight = headerHeight, onHeaderSelected = onHeaderSelected, ) AvatarInEdit( uiState = uiState, avatarSize = avatarSize, onAvatarSelected = onAvatarSelected, ) } OutlinedTextField( modifier = Modifier .padding(start = 16.dp, top = 16.dp, end = 16.dp) .fillMaxWidth(), value = uiState.name, onValueChange = onUserNameChanged, placeholder = { Text( modifier = Modifier.alpha(0.7F), text = stringResource(LocalizedString.editProfileInputNameHint) ) }, label = { Text(text = stringResource(LocalizedString.editProfileLabelName)) }, ) OutlinedTextField( modifier = Modifier .padding(start = 16.dp, top = 16.dp, end = 16.dp) .fillMaxWidth(), value = uiState.description, onValueChange = onBioChanged, placeholder = { Text( modifier = Modifier.alpha(0.7F), text = stringResource(LocalizedString.editProfileInputNoteHint), ) }, label = { Text(text = stringResource(LocalizedString.editProfileLabelNote)) }, ) AccountFieldListUi( uiState = uiState, onFieldChanged = onFieldChanged, onFieldDeleteClick = onFieldDeleteClick, onFieldAddClick = onFieldAddClick, ) } } } @Composable private fun AccountFieldListUi( uiState: EditAccountUiState, onFieldChanged: (Int, String, String) -> Unit, onFieldDeleteClick: (Int) -> Unit, onFieldAddClick: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, top = 16.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = stringResource(LocalizedString.activity_pub_edit_account_info_label_about), style = MaterialTheme.typography.headlineSmall, ) Box(modifier = Modifier.weight(1F)) if (uiState.fieldAddable) { StyledIconButton( onClick = onFieldAddClick, imageVector = Icons.Default.Add, style = IconButtonStyle.STANDARD, contentDescription = "Add", ) } } Box(modifier = Modifier.height(6.dp)) uiState.fieldList.forEach { fieldUiState -> Row( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, top = 11.dp, end = 8.dp, bottom = 11.dp), ) { Column( modifier = Modifier .weight(1F) .padding(end = 8.dp) ) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = fieldUiState.name, onValueChange = { onFieldChanged( fieldUiState.idForUi, it, fieldUiState.value ) }, ) OutlinedTextField( modifier = Modifier .fillMaxWidth() .padding(top = 6.dp), value = fieldUiState.value, onValueChange = { onFieldChanged( fieldUiState.idForUi, fieldUiState.name, it ) }, ) } SimpleIconButton( onClick = { onFieldDeleteClick(fieldUiState.idForUi) }, imageVector = Icons.Default.Delete, contentDescription = "Delete", ) } } } @Composable private fun HeaderInEdit( uiState: EditAccountUiState, headerHeight: Dp, onHeaderSelected: (PlatformUri) -> Unit, ) { PickVisualMediaLauncherContainer( onResult = { it.firstOrNull()?.let(onHeaderSelected) }, ) { AutoSizeImage( uiState.header, modifier = Modifier .height(headerHeight) .fillMaxWidth() .freadPlaceholder(uiState.header.isEmpty()) .clickable { launchImage() }, contentScale = ContentScale.Crop, contentDescription = null, ) } } @Composable private fun BoxScope.AvatarInEdit( uiState: EditAccountUiState, avatarSize: Dp, onAvatarSelected: (PlatformUri) -> Unit, ) { Box( modifier = Modifier .align(Alignment.BottomStart) .padding(start = 16.dp) .size(avatarSize) .clip(CircleShape) .border(2.dp, Color.White, CircleShape) .freadPlaceholder(uiState.avatar.isEmpty()) ) { AutoSizeImage( uiState.avatar, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, contentDescription = "avatar", ) PickVisualMediaLauncherContainer( onResult = { it.firstOrNull()?.let(onAvatarSelected) }, ) { SimpleIconButton( modifier = Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.5F)) .padding(10.dp), onClick = { launchImage() }, imageVector = Icons.Default.Edit, tint = Color.White, contentDescription = "Edit Avatar", ) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/account/EditAccountInfoViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.account import androidx.lifecycle.ViewModel import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.activitypub.entities.UpdateFieldRequestEntity import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.textOf import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.utils.PlatformUri import com.zhangke.framework.utils.toPlatformUri import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.common.utils.PlatformUriHelper import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.uri.FormalUri import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update class EditAccountInfoViewModel( private val clientManager: ActivityPubClientManager, private val platformUriHelper: PlatformUriHelper, private val baseUrl: FormalBaseUrl, private val accountUri: FormalUri, ) : ViewModel() { companion object { const val FIELD_MAX_COUNT = 4 } private val _uiState = MutableStateFlow( EditAccountUiState( name = "", header = "", avatar = "", description = "", fieldList = emptyList(), fieldAddable = false, requesting = false, ) ) val uiState = _uiState.asStateFlow() private val _snackBarMessageFlow = MutableSharedFlow() val snackBarMessageFlow: SharedFlow = _snackBarMessageFlow.asSharedFlow() private val _finishPageFlow = MutableSharedFlow() val finishPageFlow = _finishPageFlow.asSharedFlow() private var originalAccountInfo: ActivityPubAccountEntity? = null private val locator = PlatformLocator(accountUri = accountUri, baseUrl = baseUrl) init { launchInViewModel { clientManager.getClient(locator) .accountRepo .getCredentialAccount() .onSuccess { updateUiStateByEntity(it) originalAccountInfo = it }.onFailure { e -> e.message?.let { textOf(it) }?.let { _snackBarMessageFlow.emit(it) } } } } fun onUserNameInput(name: String) { _uiState.update { it.copy(name = name) } } fun onUserDescriptionInput(description: String) { _uiState.update { it.copy(description = description) } } fun onFieldInput(idForUi: Int, name: String, value: String) { _uiState.update { currentState -> val fieldList = currentState.fieldList currentState.copy( fieldList = fieldList.map { if (it.idForUi == idForUi) { it.copy(name = name, value = value) } else { it } } ) } } fun onFieldDelete(idForUi: Int) { _uiState.update { currentState -> val newFieldList = currentState.fieldList .filter { it.idForUi != idForUi } currentState.copy( fieldList = newFieldList, fieldAddable = newFieldList.size < FIELD_MAX_COUNT, ) } } fun onFieldAddClick() { _uiState.update { currentState -> val newFieldList = currentState.fieldList.toMutableList() newFieldList.add( EditAccountFieldUiState( idForUi = newFieldList.size, name = "", value = "", ) ) currentState.copy( fieldList = newFieldList, fieldAddable = newFieldList.size < FIELD_MAX_COUNT, ) } } fun onAvatarSelected(uri: PlatformUri) { _uiState.update { it.copy(avatar = uri.toString()) } } fun onHeaderSelected(uri: PlatformUri) { _uiState.update { it.copy(header = uri.toString()) } } fun onEditClick() = launchInViewModel { val originalAccountInfo = originalAccountInfo ?: return@launchInViewModel val currentUiState = _uiState.value if (currentUiState.name.isBlank()) { _snackBarMessageFlow.emit(textOf(LocalizedString.editProfileNameEmpty)) return@launchInViewModel } val newName = currentUiState.name.takeIf { it != originalAccountInfo.nameOfUiState } val newNote = currentUiState.description.takeIf { it != originalAccountInfo.noteOfUiState } val newAvatar = currentUiState.avatar .takeIf { it != originalAccountInfo.avatar } ?.toPlatformUri() ?.let { platformUriHelper.read(it) } val newHeader = currentUiState.header .takeIf { it != originalAccountInfo.header } ?.toPlatformUri() ?.let { platformUriHelper.read(it) } val newFieldList = currentUiState.fieldList.takeIf { !it.compare(originalAccountInfo.fieldOfUiState) } if (newName == null && newNote == null && newAvatar == null && newHeader == null && newFieldList == null ) { return@launchInViewModel } _uiState.update { it.copy(requesting = true) } clientManager.getClient(locator) .accountRepo .updateCredentials( name = newName, note = newNote, avatarFileName = newAvatar?.fileName, avatarByteArray = newAvatar?.readBytes(), headerFileName = newHeader?.fileName, headerByteArray = newHeader?.readBytes(), fieldList = newFieldList?.map { it.toUpdateFieldRequestEntity() }, ).onSuccess { updateUiStateByEntity(it) _finishPageFlow.emit(Unit) }.onFailure { e -> e.message?.let { textOf(it) }?.let { _snackBarMessageFlow.emit(it) } _uiState.update { it.copy(requesting = false) } } } private fun updateUiStateByEntity(entity: ActivityPubAccountEntity) { originalAccountInfo = entity _uiState.update { currentState -> val fieldList = entity.fieldOfUiState currentState.copy( name = entity.nameOfUiState, header = entity.headerOfUiState, avatar = entity.avatarOfUiState, description = entity.noteOfUiState, fieldList = fieldList, fieldAddable = fieldList.size < FIELD_MAX_COUNT, requesting = false, ) } } private val ActivityPubAccountEntity.nameOfUiState: String get() = displayName private val ActivityPubAccountEntity.headerOfUiState: String get() = header private val ActivityPubAccountEntity.avatarOfUiState: String get() = avatar private val ActivityPubAccountEntity.noteOfUiState: String get() = source?.note.orEmpty() private val ActivityPubAccountEntity.fieldOfUiState: List get() = source?.fields?.mapIndexed { index, field -> EditAccountFieldUiState( idForUi = index, name = field.name, value = field.value ) } ?: emptyList() /** * Compare theos two list is same or not */ private fun List.compare( original: List ): Boolean { if (size != original.size) { return false } this.forEach { item -> val originalState = original.firstOrNull { it.idForUi == item.idForUi } if (originalState == null || originalState.name != item.name || originalState.value != item.value) { return false } } return true } private fun EditAccountFieldUiState.toUpdateFieldRequestEntity() = UpdateFieldRequestEntity( name = name, value = value ) } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/account/EditAccountUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.account data class EditAccountUiState( val name: String, val header: String, val avatar: String, val description: String, val fieldList: List, val fieldAddable: Boolean, val requesting: Boolean, ) data class EditAccountFieldUiState( val idForUi: Int, val name: String, val value: String, ) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/add/AddActivityPubContentScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.add import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.EaseOutBack import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.commonbiz.Res import com.zhangke.fread.commonbiz.emoji_celebrate import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.ui.source.BlogPlatformCard import kotlinx.coroutines.delay import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @Serializable data class AddActivityPubContentScreenKey(val platform: BlogPlatform) : NavKey @Composable fun AddActivityPubContentScreen( platform: BlogPlatform, viewModel: AddActivityPubContentViewModel, ) { val backStack = LocalNavBackStack.currentOrThrow AddActivityPubContentContent( platform = platform, onBackClick = { backStack.removeLastOrNull() }, onLoginClick = { viewModel.onLoginClick() backStack.removeLastOrNull() }, ) } @Composable private fun AddActivityPubContentContent( platform: BlogPlatform, onBackClick: () -> Unit, onLoginClick: () -> Unit, ) { Scaffold( topBar = { Toolbar( title = stringResource(LocalizedString.addContentTitle), onBackClick = onBackClick, ) }, ) { innerPadding -> Column( modifier = Modifier.fillMaxSize() .padding(innerPadding) .padding(top = 32.dp) .padding(horizontal = 16.dp), ) { ContentAddingState(Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) Spacer(modifier = Modifier.height(16.dp)) PlatformPreview( modifier = Modifier .align(Alignment.CenterHorizontally) .fillMaxWidth(), platform = platform, onLoginClick = onLoginClick, ) Spacer(modifier = Modifier.padding(top = 16.dp)) } } } @Composable private fun ContentAddingState(modifier: Modifier) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { val scale = remember { Animatable(0.1F) } LaunchedEffect(Unit) { delay(100) scale.animateTo( targetValue = 1f, animationSpec = tween( durationMillis = 600, easing = EaseOutBack, ) ) } Image( modifier = Modifier.size(68.dp) .scale(scale.value), painter = painterResource(Res.drawable.emoji_celebrate), contentDescription = null, ) Text( text = stringResource(LocalizedString.contentAddSuccess), modifier = Modifier.padding(top = 8.dp), ) } } @Composable private fun PlatformPreview( modifier: Modifier, platform: BlogPlatform, onLoginClick: () -> Unit, ) { BlogPlatformCard( modifier = modifier, platform = platform, onLoginClick = onLoginClick, ) } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/add/AddActivityPubContentViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.add import androidx.lifecycle.ViewModel import com.zhangke.framework.collections.container import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubContentAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubOAuthor import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.common.content.FreadContentRepo import com.zhangke.fread.common.onboarding.OnboardingComponent import com.zhangke.fread.status.platform.BlogPlatform class AddActivityPubContentViewModel ( private val contentRepo: FreadContentRepo, private val oAuthor: ActivityPubOAuthor, private val contentAdapter: ActivityPubContentAdapter, private val onboardingComponent: OnboardingComponent, private val platform: BlogPlatform, ) : ViewModel() { init { launchInViewModel { val content = contentAdapter.createContent( platform = platform, maxOrder = contentRepo.getMaxOrder(), ) val allContent = contentRepo.getAllContent().filterIsInstance() if (!allContent.container { it.id == content.id }) { contentRepo.insertContent(content) } } } fun onLoginClick() { launchInViewModel { onboardingComponent.onboardingSuccess() } oAuthor.startOauth(platform.baseUrl) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/add/select/SelectPlatformScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.add.select import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeOpenScreenFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.LoadingDialog import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.ui.source.BlogPlatformSnapshotUi import com.zhangke.fread.status.ui.source.BlogPlatformUi import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable object SelectPlatformScreenKey : NavKey @Composable fun SelectPlatformScreen(viewModel: SelectPlatformViewModel) { val backStack = LocalNavBackStack.currentOrThrow val uiState by viewModel.uiState.collectAsState() val snackbarHostState = rememberSnackbarHostState() SelectPlatformContent( uiState = uiState, snackbarHostState = snackbarHostState, onBackClick = { backStack.removeLastOrNull() }, onQueryChanged = viewModel::onQueryChanged, onSearchClick = viewModel::onSearchClick, onPlatformClick = viewModel::onResultClick, ) ConsumeOpenScreenFlow(viewModel.openNewPageFlow) ConsumeSnackbarFlow(snackbarHostState, viewModel.snackBarMessage) ConsumeFlow(viewModel.finishPageFlow) { backStack.removeLastOrNull() } LoadingDialog( loading = uiState.loadingPlatformForAdd, onDismissRequest = viewModel::onLoadingPlatformForAddCancel, ) LaunchedEffect(Unit) { viewModel.onPageResumed(this) } } @Composable private fun SelectPlatformContent( uiState: SelectPlatformUiState, snackbarHostState: SnackbarHostState, onBackClick: () -> Unit, onQueryChanged: (String) -> Unit, onSearchClick: () -> Unit, onPlatformClick: (SearchPlatformResult) -> Unit, ) { Scaffold( modifier = Modifier.fillMaxSize(), topBar = { Toolbar( title = stringResource(LocalizedString.activity_pub_select_platform_title), onBackClick = onBackClick, ) }, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { innerPadding -> Column( modifier = Modifier.fillMaxSize() .padding(innerPadding), ) { OutlinedTextField( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, top = 18.dp, end = 16.dp), value = uiState.query, onValueChange = onQueryChanged, maxLines = 1, keyboardOptions = KeyboardOptions.Default.copy( imeAction = ImeAction.Search ), placeholder = { Text( text = stringResource(LocalizedString.activity_pub_select_platform_text_hint), style = MaterialTheme.typography.bodyMedium, ) }, keyboardActions = KeyboardActions( onSearch = { onSearchClick() } ), trailingIcon = { if (uiState.querying) { CircularProgressIndicator( modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary, ) } else { SimpleIconButton( onClick = onSearchClick, imageVector = Icons.Default.Search, contentDescription = "Search", ) } }, ) Spacer(modifier = Modifier.height(16.dp)) if (uiState.searchedResult.isNotEmpty()) { LazyColumn( modifier = Modifier.fillMaxSize(), ) { items(uiState.searchedResult) { SearchPlatformResultUi( result = it, onClick = onPlatformClick, ) } } } else { LazyColumn( modifier = Modifier.fillMaxSize(), ) { items(uiState.platformSnapshotList) { SearchPlatformResultUi( result = it, onClick = onPlatformClick, ) } } } } } } @Composable private fun SearchPlatformResultUi( result: SearchPlatformResult, onClick: (SearchPlatformResult) -> Unit, ) { when (result) { is SearchPlatformResult.SearchedPlatform -> BlogPlatformUi( modifier = Modifier .fillMaxWidth() .clickable { onClick(result) }, platform = result.platform, ) is SearchPlatformResult.SearchedSnapshot -> BlogPlatformSnapshotUi( modifier = Modifier .fillMaxWidth() .clickable { onClick(result) }, platform = result.snapshot, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/add/select/SelectPlatformUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.add.select import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.platform.PlatformSnapshot data class SelectPlatformUiState( val query: String, val platformSnapshotList: List, val querying: Boolean, val searchedResult: List, val loadingPlatformForAdd: Boolean, ) { companion object { fun default(): SelectPlatformUiState { return SelectPlatformUiState( query = "", platformSnapshotList = emptyList(), querying = false, searchedResult = emptyList(), loadingPlatformForAdd = false, ) } } } sealed interface SearchPlatformResult { data class SearchedSnapshot(val snapshot: PlatformSnapshot) : SearchPlatformResult data class SearchedPlatform(val platform: BlogPlatform) : SearchPlatformResult } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/add/select/SelectPlatformViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.add.select import androidx.lifecycle.ViewModel import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.composable.textOf import com.zhangke.framework.coroutines.invokeOnCancel import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import com.zhangke.fread.activitypub.app.internal.screen.add.AddActivityPubContentScreenKey import com.zhangke.fread.common.onboarding.OnboardingComponent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class SelectPlatformViewModel ( private val platformRepo: ActivityPubPlatformRepo, private val onboardingComponent: OnboardingComponent, ) : ViewModel() { private val _uiState = MutableStateFlow(SelectPlatformUiState.default()) val uiState = _uiState.asStateFlow() private val _openNewPageFlow = MutableSharedFlow() val openNewPageFlow = _openNewPageFlow.asSharedFlow() private val _finishPageFlow = MutableSharedFlow() val finishPageFlow = _finishPageFlow.asSharedFlow() private val _snackBarMessage = MutableSharedFlow() val snackBarMessage = _snackBarMessage.asSharedFlow() private var queryJob: Job? = null private var loadingPlatformForAddJob: Job? = null init { onboardingComponent.clearState() launchInViewModel { platformRepo.getSuggestedPlatformSnapshotList() .map { SearchPlatformResult.SearchedSnapshot(it) } .let { snapshots -> _uiState.update { it.copy(platformSnapshotList = snapshots) } } } } fun onPageResumed(uiScope: CoroutineScope) { uiScope.launch { onboardingComponent.onboardingFinishedFlow.collect { _finishPageFlow.emit(Unit) } } } fun onSearchClick() { doSearch(_uiState.value.query) } fun onQueryChanged(query: String) { if (query == _uiState.value.query) return _uiState.update { it.copy(query = query) } if (query.isEmpty()) { if (queryJob?.isActive == true) queryJob?.cancel() _uiState.update { it.copy(searchedResult = emptyList(), querying = false) } return } doSearch(query) } private fun doSearch(query: String) { if (queryJob?.isActive == true) queryJob?.cancel() queryJob = launchInViewModel { _uiState.update { it.copy(querying = true) } val localResult = platformRepo.searchPlatformSnapshotFromLocal(query) .map { SearchPlatformResult.SearchedSnapshot(it) } _uiState.update { it.copy(searchedResult = localResult) } val platformAsUrl = FormalBaseUrl.parse(query) ?.let { platformRepo.getPlatform(it).getOrNull() } ?.let { SearchPlatformResult.SearchedPlatform(it) } if (platformAsUrl != null) { _uiState.update { val newList = (it.searchedResult + platformAsUrl).distinctByDomain() it.copy(searchedResult = newList) } } platformRepo.searchPlatformFromServer(query) .map { list -> list.map { SearchPlatformResult.SearchedSnapshot(it) } } .onSuccess { result -> _uiState.update { it.copy(searchedResult = (it.searchedResult + result).distinctByDomain()) } } _uiState.update { it.copy(querying = false) } } queryJob?.invokeOnCancel { _uiState.update { it.copy(querying = false) } } } private fun List.distinctByDomain(): List { return distinctBy { result -> when (result) { is SearchPlatformResult.SearchedPlatform -> result.platform.baseUrl.host is SearchPlatformResult.SearchedSnapshot -> result.snapshot.domain } } } fun onResultClick(result: SearchPlatformResult) { when (result) { is SearchPlatformResult.SearchedPlatform -> { launchInViewModel { _openNewPageFlow.emit(AddActivityPubContentScreenKey(result.platform)) } } is SearchPlatformResult.SearchedSnapshot -> { if (loadingPlatformForAddJob?.isActive == true) { loadingPlatformForAddJob?.cancel() } loadingPlatformForAddJob = launchInViewModel { val baseUrl = FormalBaseUrl.parse(result.snapshot.domain) if (baseUrl == null) { _snackBarMessage.emit(textOf("Invalid platform domain: ${result.snapshot.domain}")) return@launchInViewModel } _uiState.update { it.copy(loadingPlatformForAdd = true) } platformRepo.getPlatform(baseUrl) .onSuccess { platform -> _uiState.update { it.copy(loadingPlatformForAdd = false) } _openNewPageFlow.emit(AddActivityPubContentScreenKey(platform)) }.onFailure { t -> _uiState.update { it.copy(loadingPlatformForAdd = false) } _snackBarMessage.emitTextMessageFromThrowable(t) } } } } } fun onLoadingPlatformForAddCancel() { loadingPlatformForAddJob?.cancel() } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/content/ActivityPubContentSubViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.content import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.lifecycle.SubViewModel import com.zhangke.fread.activitypub.app.ActivityPubAccountManager import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.activitypub.app.internal.usecase.UpdateActivityPubUserListUseCase import com.zhangke.fread.activitypub.app.internal.usecase.content.GetUserCreatedListUseCase import com.zhangke.fread.activitypub.app.internal.utils.createPlatformLocator import com.zhangke.fread.common.config.FreadConfigManager import com.zhangke.fread.common.content.FreadContentRepo import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update class ActivityPubContentSubViewModel( private val contentRepo: FreadContentRepo, private val getUserCreatedList: GetUserCreatedListUseCase, private val accountManager: ActivityPubAccountManager, private val freadConfigManager: FreadConfigManager, private val updateActivityPubUserList: UpdateActivityPubUserListUseCase, val contentId: String, ) : SubViewModel() { private val _uiState = MutableStateFlow(ActivityPubContentUiState.default()) val uiState = _uiState.asStateFlow() private var updateUserListJob: Job? = null private var userCreatedListUpdated = false private var observeAccountJob: Job? = null init { launchInViewModel { contentRepo.getContentFlow(contentId) .distinctUntilChanged() .map { it as? ActivityPubContent } .collect { contentConfig -> if (contentConfig != null) { val locator = createPlatformLocator(contentConfig) _uiState.update { it.copy(locator = locator, config = contentConfig) } startObserveAccount(contentConfig) updateUserCreateList() } else { _uiState.update { it.copy( errorMessage = "Cant find validate config by id: $contentId" ) } } } } launchInViewModel { freadConfigManager.homeTabRefreshButtonVisibleFlow .collect { visible -> _uiState.update { it.copy(showRefreshButton = visible) } } } launchInViewModel { freadConfigManager.homeTabNextButtonVisibleFlow .collect { visible -> _uiState.update { it.copy(showNextButton = visible) } } } } private fun startObserveAccount(content: ActivityPubContent) { observeAccountJob?.cancel() val accountUri = content.accountUri if (accountUri == null) { _uiState.update { it.copy(account = null) } return } observeAccountJob = launchInViewModel { accountManager.observeAccount(accountUri) .distinctUntilChanged() .collect { account -> val accountCountInSamePlatform = if (account == null) { 0 } else { accountManager.getAllLoggedAccount().count { it.baseUrl == account.baseUrl } } _uiState.update { it.copy( account = account, showAccountInTopBar = accountCountInSamePlatform > 1 ) } userCreatedListUpdated = false updateUserCreateList() } } } private fun updateUserCreateList() { if (userCreatedListUpdated) return userCreatedListUpdated = true updateUserListJob?.cancel() val locator = _uiState.value.locator ?: return updateUserListJob = launchInViewModel { getUserCreatedList(locator) .map { list -> list.map { // 此处的 order 并不会使用,repo 内部会重新计算,因此次数放一个较大的值填充即可。 ActivityPubContent.ContentTab.ListTimeline( listId = it.id, name = it.title, order = 1000, ) } } .onSuccess { list -> _uiState.value.config?.let { updateActivityPubUserList(it, list) } } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/content/ActivityPubContentTab.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.content import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.LocalContentPadding import com.zhangke.framework.composable.LocalSnackbarHostState import com.zhangke.framework.composable.contentBottomPadding import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.plusContentPadding import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.BaseTab import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.Tab import com.zhangke.framework.nav.TabOptions import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.model.ActivityPubStatusSourceType import com.zhangke.fread.activitypub.app.internal.screen.content.timeline.ActivityPubTimelineTab import com.zhangke.fread.activitypub.app.internal.screen.instance.InstanceDetailScreenKey import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusScreenKey import com.zhangke.fread.activitypub.app.internal.screen.trending.TrendingStatusTab import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.ui.common.HomeContentTabsTopBar import com.zhangke.fread.status.ui.common.LocalNestedTabConnection import com.zhangke.fread.status.ui.common.PublishingFab import com.zhangke.fread.status.ui.style.LocalStatusUiConfig import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel internal class ActivityPubContentTab( private val configId: String, private val isLatestContent: Boolean, ) : BaseTab() { override val options: TabOptions? @Composable get() = null @Composable override fun Content() { super.Content() val navBackStack = LocalNavBackStack.currentOrThrow val viewModel = koinViewModel().getSubViewModel(configId) val uiState by viewModel.uiState.collectAsState() ActivityPubContentUi( uiState = uiState, onTitleClick = { content -> uiState.locator?.let { navBackStack.add(InstanceDetailScreenKey(it, content.baseUrl)) } }, onPostBlogClick = { navBackStack.add(PostStatusScreenKey(accountUri = it.uri)) }, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ActivityPubContentUi( uiState: ActivityPubContentUiState, onTitleClick: (ActivityPubContent) -> Unit, onPostBlogClick: (ActivityPubLoggedAccount) -> Unit, ) { val coroutineScope = rememberCoroutineScope() val mainTabConnection = LocalNestedTabConnection.current val snackBarHostState = rememberSnackbarHostState() val showFb = uiState.account != null val tabList = remember(uiState.locator, uiState.config) { if (uiState.locator != null && uiState.config != null) { createTabs(uiState.locator, uiState.config) } else { emptyList() } } val tabTitles = tabList.map { it.options?.title.orEmpty() } val pagerState = rememberPagerState(0) { tabList.size } val topBarState = rememberTopAppBarState() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topBarState) LaunchedEffect(mainTabConnection, topBarState) { mainTabConnection.scrollToTopFlow.collect { topBarState.contentOffset = 0F topBarState.heightOffset = 0F } } Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { if (uiState.config != null && tabList.isNotEmpty()) { HomeContentTabsTopBar( title = uiState.config.name, account = uiState.account, showAccountInfo = uiState.showAccountInTopBar, selectedTabIndex = pagerState.currentPage, tabTitles = tabTitles, scrollBehavior = scrollBehavior, showNextIcon = !isLatestContent && uiState.showNextButton, showRefreshButton = uiState.showRefreshButton, onMenuClick = { coroutineScope.launch { mainTabConnection.openDrawer() } }, onRefreshClick = { coroutineScope.launch { mainTabConnection.scrollToTop() mainTabConnection.refresh() } }, onNextClick = { coroutineScope.launch { mainTabConnection.switchToNextTab() } }, onTitleClick = { onTitleClick(uiState.config) }, onDoubleClick = { coroutineScope.launch { mainTabConnection.scrollToTop() } }, onTabClick = { coroutineScope.launch { pagerState.scrollToPage(it) } }, ) } }, snackbarHost = { val modifier = if (showFb) { Modifier.navigationBarsPadding() } else { Modifier.navigationBarsPadding().contentBottomPadding() } SnackbarHost( modifier = modifier, hostState = snackBarHostState, ) }, contentWindowInsets = WindowInsets(0, 0, 0, 0), floatingActionButton = { if (showFb) { val inImmersiveMode by mainTabConnection.inImmersiveFlow.collectAsState() val immersiveNavBar = LocalStatusUiConfig.current.immersiveNavBar PublishingFab( visible = !inImmersiveMode || !immersiveNavBar, onPublishClick = { onPostBlogClick(uiState.account) }, ) } }, ) { paddings -> CompositionLocalProvider( LocalSnackbarHostState provides snackBarHostState, LocalContentPadding provides plusContentPadding(paddings), ) { if (uiState.locator != null && uiState.config != null) { if (tabList.isNotEmpty()) { val contentScrollInProgress by mainTabConnection.contentScrollInpProgress.collectAsState() HorizontalPager( modifier = Modifier.fillMaxSize(), state = pagerState, userScrollEnabled = !contentScrollInProgress, ) { pageIndex -> tabList[pageIndex].Content() } } } else if (!uiState.errorMessage.isNullOrBlank()) { Box( modifier = Modifier.fillMaxSize() .padding(LocalContentPadding.current), ) { Text( modifier = Modifier .padding(start = 16.dp, top = 64.dp, end = 16.dp) .fillMaxWidth() .align(Alignment.TopCenter), text = uiState.errorMessage, textAlign = TextAlign.Center, ) } } } } } private fun createTabs( locator: PlatformLocator, config: ActivityPubContent, ): List { return config .tabList .filter { !it.hide } .sortedBy { it.order } .map { it.toPagerTab(locator) } } private fun ActivityPubContent.ContentTab.toPagerTab(locator: PlatformLocator): Tab { return when (this) { is ActivityPubContent.ContentTab.HomeTimeline -> { ActivityPubTimelineTab( locator = locator, type = ActivityPubStatusSourceType.TIMELINE_HOME, ) } is ActivityPubContent.ContentTab.LocalTimeline -> { ActivityPubTimelineTab( locator = locator, type = ActivityPubStatusSourceType.TIMELINE_LOCAL, ) } is ActivityPubContent.ContentTab.PublicTimeline -> { ActivityPubTimelineTab( locator = locator, type = ActivityPubStatusSourceType.TIMELINE_PUBLIC, ) } is ActivityPubContent.ContentTab.Trending -> { TrendingStatusTab(locator = locator) } is ActivityPubContent.ContentTab.ListTimeline -> { ActivityPubTimelineTab( locator = locator, type = ActivityPubStatusSourceType.LIST, listId = listId, listTitle = name, ) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/content/ActivityPubContentUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.content import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.status.model.PlatformLocator data class ActivityPubContentUiState( val locator: PlatformLocator?, val config: ActivityPubContent?, val account: ActivityPubLoggedAccount?, val showAccountInTopBar: Boolean, val errorMessage: String?, val showRefreshButton: Boolean, val showNextButton: Boolean, ) { companion object { fun default(): ActivityPubContentUiState { return ActivityPubContentUiState( locator = null, config = null, account = null, showAccountInTopBar = false, errorMessage = null, showRefreshButton = false, showNextButton = false, ) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/content/ActivityPubContentViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.content import com.zhangke.framework.lifecycle.ContainerViewModel import com.zhangke.fread.activitypub.app.ActivityPubAccountManager import com.zhangke.fread.activitypub.app.internal.usecase.UpdateActivityPubUserListUseCase import com.zhangke.fread.activitypub.app.internal.usecase.content.GetUserCreatedListUseCase import com.zhangke.fread.common.config.FreadConfigManager import com.zhangke.fread.common.content.FreadContentRepo class ActivityPubContentViewModel ( private val contentRepo: FreadContentRepo, private val freadConfigManager: FreadConfigManager, private val accountManager: ActivityPubAccountManager, private val getUserCreatedList: GetUserCreatedListUseCase, private val updateActivityPubUserList: UpdateActivityPubUserListUseCase, ) : ContainerViewModel() { override fun createSubViewModel(params: Params): ActivityPubContentSubViewModel { return ActivityPubContentSubViewModel( contentRepo = contentRepo, getUserCreatedList = getUserCreatedList, freadConfigManager = freadConfigManager, accountManager = accountManager, contentId = params.contentId, updateActivityPubUserList = updateActivityPubUserList, ) } fun getSubViewModel(contentId: String): ActivityPubContentSubViewModel { val params = Params(contentId) return obtainSubViewModel(params) } class Params(val contentId: String) : SubViewModelParams() { override val key: String get() = contentId.toString() } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/content/edit/EditContentConfigScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.content.edit import androidx.compose.animation.core.animateDpAsState 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key 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.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.freadPlaceholder import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.activitypub.app.internal.composable.tabName import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.ui.bar.EditContentTopBar import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable import org.burnoutcrew.reorderable.ReorderableItem import org.burnoutcrew.reorderable.detectReorderAfterLongPress import org.burnoutcrew.reorderable.rememberReorderableLazyListState import org.burnoutcrew.reorderable.reorderable import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @Serializable data class EditContentConfigScreenKey(val contentId: String) : NavKey private val TAB_ITEM_HEIGHT = 62.dp @Composable fun EditContentConfigScreen( contentId: String, viewModel: EditContentConfigViewModel = koinViewModel { parametersOf(contentId) }, ) { val backStack = LocalNavBackStack.currentOrThrow val uiState by viewModel.uiState.collectAsState() EditContentConfigScreenContent( uiState = uiState, snackbarMessageFlow = viewModel.snackbarMessageFlow, onBackClick = { backStack.removeLastOrNull() }, onShowingTabMove = viewModel::onShowingTabMove, onShowingTabMoveDown = viewModel::onShowingTabMoveDown, onHiddenTabMoveUp = viewModel::onHiddenTabMoveUp, onDeleteClick = viewModel::onDeleteClick, onEditNameClick = viewModel::onEditNameClick, ) ConsumeFlow(viewModel.finishScreenFlow) { backStack.removeLastOrNull() } } @Composable private fun EditContentConfigScreenContent( uiState: EditContentConfigUiState?, snackbarMessageFlow: Flow, onBackClick: () -> Unit, onShowingTabMove: (from: Int, to: Int) -> Unit, onShowingTabMoveDown: (ActivityPubContent.ContentTab) -> Unit, onHiddenTabMoveUp: (ActivityPubContent.ContentTab) -> Unit, onEditNameClick: (String) -> Unit, onDeleteClick: () -> Unit, ) { val snackbarHostState = rememberSnackbarHostState() ConsumeSnackbarFlow(snackbarHostState, snackbarMessageFlow) Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { EditContentTopBar( contentName = uiState?.content?.name.orEmpty(), onBackClick = onBackClick, onNameEdit = onEditNameClick, onDeleteClick = onDeleteClick, ) } ) { innerPaddings -> Column( modifier = Modifier .padding(innerPaddings) .fillMaxSize() .freadPlaceholder(uiState == null) .verticalScroll(rememberScrollState()) ) { if (uiState != null) { ShowingUserList( uiState = uiState, onShowingTabMove = onShowingTabMove, onMoveDown = onShowingTabMoveDown, ) Spacer(modifier = Modifier.height(16.dp)) HiddenUserList( uiState = uiState, onMoveUp = onHiddenTabMoveUp, ) } } } } @Composable private fun ShowingUserList( uiState: EditContentConfigUiState, onShowingTabMove: (from: Int, to: Int) -> Unit, onMoveDown: (ActivityPubContent.ContentTab) -> Unit, ) { Text( modifier = Modifier.padding(start = 16.dp, top = 16.dp), text = stringResource(LocalizedString.statusUiEditContentConfigShowingListTitle), style = MaterialTheme.typography.titleMedium, ) Text( modifier = Modifier.padding(start = 16.dp, top = 2.dp), text = stringResource(LocalizedString.statusUiEditContentConfigShowingListDescription), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) var tabsInUi by remember(uiState.content.showingTabList) { mutableStateOf(uiState.content.showingTabList) } key(uiState.content.showingTabList) { val state = rememberReorderableLazyListState( onMove = { from, to -> if (tabsInUi.isEmpty()) return@rememberReorderableLazyListState tabsInUi = tabsInUi.toMutableList().apply { add(to.index, removeAt(from.index)) } }, onDragEnd = { startIndex, endIndex -> onShowingTabMove(startIndex, endIndex) }, ) LazyColumn( state = state.listState, modifier = Modifier .padding(top = 16.dp) .fillMaxWidth() .height(TAB_ITEM_HEIGHT * tabsInUi.size + 4.dp) .reorderable(state) .detectReorderAfterLongPress(state), ) { itemsIndexed( items = tabsInUi, key = { _, item -> item.uiKey } ) { _, tabItem -> ReorderableItem(state, tabItem.uiKey) { dragging -> val shadowElevation by animateDpAsState( if (dragging) 8.dp else 2.dp, label = "" ) val tonalElevation by animateDpAsState( if (dragging) 16.dp else 2.dp, label = "" ) Surface( modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth() .height(TAB_ITEM_HEIGHT), tonalElevation = tonalElevation, shadowElevation = shadowElevation, ) { Row( modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = Modifier.padding(start = 16.dp), text = tabItem.tabName(), style = MaterialTheme.typography.bodyMedium, ) Spacer(modifier = Modifier.weight(1F)) SimpleIconButton( onClick = { onMoveDown(tabItem) }, imageVector = Icons.Default.VisibilityOff, contentDescription = "Move Down", ) Spacer(modifier = Modifier.width(16.dp)) Icon( imageVector = Icons.Default.Menu, contentDescription = null, ) Spacer(modifier = Modifier.width(16.dp)) } } } } } } } @Composable private fun HiddenUserList( uiState: EditContentConfigUiState, onMoveUp: (ActivityPubContent.ContentTab) -> Unit, ) { Text( modifier = Modifier.padding(start = 16.dp, top = 24.dp), text = stringResource(LocalizedString.statusUiEditContentConfigHiddenListTitle), style = MaterialTheme.typography.titleMedium, ) Spacer(modifier = Modifier.height(16.dp)) if (uiState.content.hidingTabList.isEmpty()) { Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center, ) { Text( modifier = Modifier, text = stringResource(LocalizedString.empty), style = MaterialTheme.typography.bodyMedium, ) } } else { uiState.content.hidingTabList.forEach { tabItem -> Surface( modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth() .height(TAB_ITEM_HEIGHT), tonalElevation = 2.dp, shadowElevation = 2.dp, ) { Row( modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = Modifier.padding(start = 16.dp), text = tabItem.tabName(), style = MaterialTheme.typography.bodyMedium, ) Spacer(modifier = Modifier.weight(1F)) SimpleIconButton( onClick = { onMoveUp(tabItem) }, imageVector = Icons.Default.Visibility, contentDescription = "Move Up", ) Spacer(modifier = Modifier.width(8.dp)) } } } } } private val ActivityPubContent.showingTabList: List get() = tabList.filter { !it.hide } private val ActivityPubContent.hidingTabList: List get() = tabList.filter { it.hide } private val ActivityPubContent.ContentTab.uiKey: String get() = "${this::class.simpleName}@${hashCode()}" ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/content/edit/EditContentConfigUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.content.edit import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent data class EditContentConfigUiState ( val content: ActivityPubContent, ) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/content/edit/EditContentConfigViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.content.edit import androidx.lifecycle.ViewModel import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.textOf import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.activitypub.app.internal.usecase.content.ReorderActivityPubTabUseCase import com.zhangke.fread.common.content.FreadContentRepo import com.zhangke.fread.localization.LocalizedString import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow class EditContentConfigViewModel ( private val contentRepo: FreadContentRepo, private val reorderTab: ReorderActivityPubTabUseCase, private val contentId: String ) : ViewModel() { private val _uiState = MutableStateFlow(null) val uiState = _uiState.asStateFlow() private val _snackbarMessageFlow = MutableSharedFlow() val snackbarMessageFlow = _snackbarMessageFlow.asSharedFlow() private val _finishScreenFlow = MutableSharedFlow() val finishScreenFlow = _finishScreenFlow.asSharedFlow() init { launchInViewModel { contentRepo.getContentFlow(contentId) .collect { content -> if (content !is ActivityPubContent) { _snackbarMessageFlow.emit(textOf(LocalizedString.activity_pub_edit_content_screen_config_not_found)) return@collect } _uiState.value = EditContentConfigUiState(content) } } } fun onShowingTabMove(from: Int, to: Int) { val uiState = _uiState.value ?: return val content = uiState.content launchInViewModel { reorderTab( content = content, fromTab = content.tabList[from], toTab = content.tabList[to], ) } } fun onShowingTabMoveDown(tab: ActivityPubContent.ContentTab) { launchInViewModel { updateTabHideState(tab, true) } } fun onHiddenTabMoveUp(tab: ActivityPubContent.ContentTab) { launchInViewModel { updateTabHideState(tab, false) } } private suspend fun updateTabHideState( tab: ActivityPubContent.ContentTab, hide: Boolean, ) { val content = _uiState.value?.content ?: return val newContent = content.copy( tabList = content.tabList.map { if (it == tab) { it.updateHide(hide) } else { it } } ) contentRepo.insertContent(newContent) } fun onDeleteClick() { launchInViewModel { contentRepo.delete(contentId) _finishScreenFlow.emit(Unit) } } fun onEditNameClick(contentName: String) { launchInViewModel { if (contentRepo.checkNameExist(contentName)) { _snackbarMessageFlow.emit(textOf(LocalizedString.addFeedsPageEmptyNameExist)) return@launchInViewModel } val newContent = contentRepo.getContent(contentId) ?.let { it as? ActivityPubContent } ?.copy(name = contentName) ?: return@launchInViewModel contentRepo.insertContent(newContent) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/content/timeline/ActivityPubTimelineContainerViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.content.timeline import com.zhangke.framework.lifecycle.ContainerViewModel import com.zhangke.fread.activitypub.app.ActivityPubAccountManager import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.model.ActivityPubStatusSourceType import com.zhangke.fread.activitypub.app.internal.repo.status.ActivityPubStatusReadStateRepo import com.zhangke.fread.activitypub.app.internal.repo.status.ActivityPubTimelineStatusRepo import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.config.FreadConfigManager import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.PlatformLocator class ActivityPubTimelineContainerViewModel ( private val statusProvider: StatusProvider, private val statusUpdater: StatusUpdater, private val statusAdapter: ActivityPubStatusAdapter, private val statusUiStateAdapter: StatusUiStateAdapter, private val refactorToNewStatus: RefactorToNewStatusUseCase, private val loggedAccountProvider: LoggedAccountProvider, private val timelineRepo: ActivityPubTimelineStatusRepo, private val accountManager: ActivityPubAccountManager, private val statusReadStateRepo: ActivityPubStatusReadStateRepo, private val freadConfigManager: FreadConfigManager, ) : ContainerViewModel() { override fun createSubViewModel(params: Params): ActivityPubTimelineViewModel { return ActivityPubTimelineViewModel( statusProvider = statusProvider, statusUpdater = statusUpdater, statusUiStateAdapter = statusUiStateAdapter, statusAdapter = statusAdapter, loggedAccountProvider = loggedAccountProvider, freadConfigManager = freadConfigManager, refactorToNewStatus = refactorToNewStatus, statusReadStateRepo = statusReadStateRepo, accountManager = accountManager, timelineRepo = timelineRepo, locator = params.locator, type = params.type, listId = params.listId, ) } fun getSubViewModel( locator: PlatformLocator, type: ActivityPubStatusSourceType, listId: String? ): ActivityPubTimelineViewModel { return obtainSubViewModel( Params( locator = locator, type = type, listId = listId, ) ) } class Params( val locator: PlatformLocator, val type: ActivityPubStatusSourceType, val listId: String?, ) : SubViewModelParams() { override val key: String get() = locator.toString() + type + listId } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/content/timeline/ActivityPubTimelineTab.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.content.timeline import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.zhangke.framework.composable.ConsumeOpenScreenFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.LocalSnackbarHostState import com.zhangke.framework.composable.textOf import com.zhangke.framework.loadable.lazycolumn.LoadableInlineVideoLazyColumn import com.zhangke.framework.loadable.lazycolumn.rememberLoadableInlineVideoLazyColumnState import com.zhangke.framework.nav.BaseTab import com.zhangke.framework.nav.TabOptions import com.zhangke.fread.activitypub.app.internal.composable.ActivityPubTabNames import com.zhangke.fread.activitypub.app.internal.model.ActivityPubStatusSourceType import com.zhangke.fread.commonbiz.shared.composable.FeedsStatusNode import com.zhangke.fread.commonbiz.shared.composable.InitErrorContent import com.zhangke.fread.commonbiz.shared.composable.ObserveForFeedsConnection import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.ui.ComposedStatusInteraction import com.zhangke.fread.status.ui.StatusListPlaceholder import com.zhangke.fread.status.ui.common.ObserveScrollStopedPosition import org.koin.compose.viewmodel.koinViewModel internal class ActivityPubTimelineTab( private val locator: PlatformLocator, private val type: ActivityPubStatusSourceType, private val listId: String? = null, private val listTitle: String? = null, ) : BaseTab() { override val options: TabOptions @Composable get() = TabOptions( title = when (type) { ActivityPubStatusSourceType.TIMELINE_HOME -> ActivityPubTabNames.homeTimeline ActivityPubStatusSourceType.TIMELINE_LOCAL -> ActivityPubTabNames.localTimeline ActivityPubStatusSourceType.TIMELINE_PUBLIC -> ActivityPubTabNames.publicTimeline ActivityPubStatusSourceType.LIST -> listTitle!! } ) @Composable override fun Content() { super.Content() val viewModel = koinViewModel() .getSubViewModel(locator, type, listId) val uiState by viewModel.uiState.collectAsState() val snackbarHostState = LocalSnackbarHostState.current ActivityPubTimelineContent( uiState = uiState, composedStatusInteraction = viewModel.composedStatusInteraction, onJumpedToStatus = viewModel::onJumpedToStatus, onLoadPrevious = viewModel::onLoadPreviousPage, onRefresh = viewModel::onRefresh, onLoadMore = viewModel::onLoadMore, onReadPositionIndexChanged = viewModel::updateMaxReadStatus, ) ConsumeSnackbarFlow(snackbarHostState, viewModel.errorMessageFlow) ConsumeOpenScreenFlow(viewModel.openScreenFlow) } @Composable private fun ActivityPubTimelineContent( uiState: ActivityPubTimelineUiState, composedStatusInteraction: ComposedStatusInteraction, onJumpedToStatus: () -> Unit, onLoadPrevious: () -> Unit, onLoadMore: () -> Unit, onRefresh: () -> Unit, onReadPositionIndexChanged: (ActivityPubTimelineItem) -> Unit, ) { Box(modifier = Modifier.fillMaxSize()) { if (uiState.items.isEmpty()) { if (uiState.showPagingLoadingPlaceholder) { StatusListPlaceholder() } else { InitErrorContent( errorMessage = uiState.pageErrorContent ?: textOf(LocalizedString.unknownError), onRetryClick = onRefresh, ) } } else { Box(modifier = Modifier.fillMaxSize()) { val state = rememberLoadableInlineVideoLazyColumnState( onRefresh = onRefresh, onLoadMore = onLoadMore, initialFirstVisibleItemIndex = uiState.initialShowIndex, ) val lazyListState = state.lazyListState ObserveScrollStopedPosition(lazyListState) { uiState.items.getOrNull(it) ?.let { item -> onReadPositionIndexChanged(item) } } ObserveForFeedsConnection( listState = lazyListState, onRefresh = onRefresh, ) LaunchedEffect(uiState.jumpToStatusId) { if (!uiState.jumpToStatusId.isNullOrEmpty()) { val jumpToIndex = uiState.items.getIndexByIdOrNull(uiState.jumpToStatusId) if (jumpToIndex >= 0 && jumpToIndex < uiState.items.size) { onJumpedToStatus() state.lazyListState.animateScrollToItem(jumpToIndex) } } } LoadableInlineVideoLazyColumn( modifier = Modifier.fillMaxSize(), state = state, onLoadPrevious = { onLoadPrevious() }, refreshing = uiState.refreshing, loadState = uiState.loadMoreState, ) { itemsIndexed( items = uiState.items, key = { _, item -> (item as ActivityPubTimelineItem.StatusItem).status.status.id }, ) { index, item -> FeedsStatusNode( modifier = Modifier.fillMaxWidth(), status = (item as ActivityPubTimelineItem.StatusItem).status, composedStatusInteraction = composedStatusInteraction, indexInList = index, ) } } } } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/content/timeline/ActivityPubTimelineViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.content.timeline import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.composable.toTextStringOrNull import com.zhangke.framework.coroutines.invokeOnCancel import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.lifecycle.SubViewModel import com.zhangke.framework.utils.LoadState import com.zhangke.fread.activitypub.app.ActivityPubAccountManager import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.model.ActivityPubStatusSourceType import com.zhangke.fread.activitypub.app.internal.repo.status.ActivityPubStatusReadStateRepo import com.zhangke.fread.activitypub.app.internal.repo.status.ActivityPubTimelineStatusRepo import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.config.FreadConfigManager import com.zhangke.fread.common.config.TimelineDefaultPosition import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.feeds.IInteractiveHandler import com.zhangke.fread.commonbiz.shared.feeds.InteractiveHandleResult import com.zhangke.fread.commonbiz.shared.feeds.InteractiveHandler import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.richtext.preParseStatus import com.zhangke.fread.status.richtext.preParseStatusList import com.zhangke.fread.status.status.model.Status import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update class ActivityPubTimelineViewModel( private val statusProvider: StatusProvider, statusUpdater: StatusUpdater, private val statusUiStateAdapter: StatusUiStateAdapter, private val statusAdapter: ActivityPubStatusAdapter, refactorToNewStatus: RefactorToNewStatusUseCase, private val timelineRepo: ActivityPubTimelineStatusRepo, private val statusReadStateRepo: ActivityPubStatusReadStateRepo, private val accountManager: ActivityPubAccountManager, private val loggedAccountProvider: LoggedAccountProvider, private val freadConfigManager: FreadConfigManager, private val locator: PlatformLocator, private val type: ActivityPubStatusSourceType, private val listId: String?, ) : SubViewModel(), IInteractiveHandler by InteractiveHandler( statusProvider = statusProvider, statusUpdater = statusUpdater, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, ) { private val _uiState = MutableStateFlow(ActivityPubTimelineUiState.default()) val uiState = _uiState.asStateFlow() private var initFeedsJob: Job? = null private var refreshJob: Job? = null private var loadMoreJob: Job? = null private var loadPreviousJob: Job? = null init { initInteractiveHandler( coroutineScope = viewModelScope, onInteractiveHandleResult = { result -> result.handle() } ) initFeeds() } private fun initFeeds() { // 1. load local first page(100 item) data // 2. load previous page of this local page data from server // 3. position item of latest read item, if can't position, move to top. initFeedsJob?.cancel() initFeedsJob = launchInViewModel { _uiState.update { it.copy( items = emptyList(), showPagingLoadingPlaceholder = true, ) } val minId = maybeLoadLocalFeeds() if (minId.isNullOrEmpty()) { timelineRepo.getFresherStatus( locator = locator, type = type, listId = listId, ) } else { timelineRepo.loadPreviousPageStatus( locator = locator, type = type, minId = minId, listId = listId, ) }.map { status -> status.preParseStatusList() val account = getAccount() status.toTimelineItems(account) }.onFailure { t -> if (_uiState.value.items.isEmpty()) { _uiState.update { it.copy( showPagingLoadingPlaceholder = false, pageErrorContent = t.toTextStringOrNull(), ) } } else { mutableErrorMessageFlow.emitTextMessageFromThrowable(t) } }.onSuccess { list -> _uiState.update { it.copy( items = list.appendItems(it.items), showPagingLoadingPlaceholder = false, ) } } } initFeedsJob!!.invokeOnCancel { _uiState.update { it.copy(showPagingLoadingPlaceholder = false) } } } /** * @return minId */ private suspend fun maybeLoadLocalFeeds(): String? { if (freadConfigManager.getTimelineDefaultPosition() == TimelineDefaultPosition.NEWEST) return null val localStatus = if (type == ActivityPubStatusSourceType.TIMELINE_LOCAL || type == ActivityPubStatusSourceType.TIMELINE_PUBLIC) { emptyList() } else { timelineRepo.getStatusFromLocal( locator = locator, type = type, listId = listId, ).map { it.preParseStatus() it } } if (localStatus.isNotEmpty()) { val latestReadStatus = statusReadStateRepo.getLatestReadId(locator, type, listId) val initialIndex = localStatus.indexOfFirst { it.id == latestReadStatus } val account = getAccount() _uiState.update { it.copy( items = localStatus.toTimelineItems(account), initialShowIndex = if (initialIndex in localStatus.indices) initialIndex else 0, showPagingLoadingPlaceholder = false, ) } } return localStatus.firstOrNull()?.id } fun onRefresh() { loadPreviousJob?.cancel() loadMoreJob?.cancel() refreshJob?.cancel() refreshJob = launchInViewModel { _uiState.update { it.copy(refreshing = true, showPagingLoadingPlaceholder = true) } val account = locator.accountUri?.let { loggedAccountProvider.getAccount(it) } timelineRepo.getFresherStatus( locator = locator, type = type, listId = listId, ).map { it.preParseStatusList() it.toTimelineItems(account) }.onFailure { t -> _uiState.update { it.copy( refreshing = false, showPagingLoadingPlaceholder = false ) } mutableErrorMessageFlow.emitTextMessageFromThrowable(t) }.onSuccess { list -> _uiState.update { it.copy( items = list, refreshing = false, showPagingLoadingPlaceholder = false, jumpToStatusId = list.firstOrNull()?.statusId, ) } } } refreshJob?.invokeOnCancel { _uiState.update { it.copy(refreshing = false) } } } fun onJumpedToStatus() { _uiState.update { it.copy(jumpToStatusId = null) } } fun onLoadPreviousPage() { if (refreshJob?.isActive == true) return if (initFeedsJob?.isActive == true) return if (loadPreviousJob?.isActive == true) return val minId = uiState.value.items.getStatusIdOrNull(0) ?: return val account = locator.accountUri?.let { loggedAccountProvider.getAccount(it) } loadPreviousJob = launchInViewModel { timelineRepo.loadPreviousPageStatus( locator = locator, type = type, minId = minId, listId = listId, ).map { it.preParseStatusList() it.toTimelineItems(account) }.onSuccess { list -> _uiState.update { it.copy( items = list.appendItems(it.items), ) } } } } fun onLoadMore() { val maxId = uiState.value .items .lastOrNull { it is ActivityPubTimelineItem.StatusItem } ?.let { it as ActivityPubTimelineItem.StatusItem } ?.status ?.status ?.id ?: return loadMoreJob?.cancel() loadMoreJob = launchInViewModel { _uiState.update { it.copy(loadMoreState = LoadState.Loading) } val account = locator.accountUri?.let { loggedAccountProvider.getAccount(it) } timelineRepo.loadMore( locator = locator, type = type, maxId = maxId, listId = listId, ).map { it.preParseStatusList() it.toTimelineItems(account) }.onFailure { t -> _uiState.update { it.copy(loadMoreState = LoadState.Failed(t.toTextStringOrNull())) } }.onSuccess { list -> _uiState.update { it.copy( items = it.items.appendItems(list), loadMoreState = LoadState.Idle, ) } } } loadMoreJob!!.invokeOnCancel { _uiState.update { it.copy(loadMoreState = LoadState.Idle) } } } fun updateMaxReadStatus(item: ActivityPubTimelineItem) { val statusId = (item as ActivityPubTimelineItem.StatusItem).status.status.id launchInViewModel { statusReadStateRepo.updateLatestReadId( locator = locator, type = type, listId = listId, latestReadId = statusId, ) } } private suspend fun InteractiveHandleResult.handle() { when (this) { is InteractiveHandleResult.UpdateStatus -> { _uiState.update { state -> state.copy(items = state.items.updateStatus(this.status)) } timelineRepo.updateStatus( locator = locator, type = type, status = this.status.status, listId = listId, ) } is InteractiveHandleResult.DeleteStatus -> { _uiState.update { state -> state.copy( items = state.items.filter { when (it) { is ActivityPubTimelineItem.StatusItem -> it.status.status.id != this.statusId } } ) } timelineRepo.deleteStatus(statusId) } is InteractiveHandleResult.UpdateFollowState -> {} } } private fun List.appendItems( items: List, ): List { if (items.isEmpty()) return this val newList = this.toMutableList() val idSet = this.map { it.statusId } val pendingAddItems = items.filter { it.statusId !in idSet } newList.addAll(pendingAddItems) return newList } private val ActivityPubTimelineItem.statusId: String get() { return when (this) { is ActivityPubTimelineItem.StatusItem -> this.status.status.id } } private fun List.toTimelineItems( loggedAccount: ActivityPubLoggedAccount?, ): List { return this.map { ActivityPubTimelineItem.StatusItem( statusAdapter.toStatusUiState( status = it, locator = locator, loggedAccount = loggedAccount, ), ) } } private fun getAccount(): ActivityPubLoggedAccount? { return locator.accountUri?.let { loggedAccountProvider.getAccount(it) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/content/timeline/ActivityPubUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.content.timeline import com.zhangke.framework.composable.TextString import com.zhangke.framework.utils.LoadState import com.zhangke.fread.status.model.StatusUiState data class ActivityPubTimelineUiState( val items: List, val showPagingLoadingPlaceholder: Boolean, val pageErrorContent: TextString?, val initialShowIndex: Int, val jumpToStatusId: String?, val refreshing: Boolean, val loadMoreState: LoadState, ) { companion object { fun default() = ActivityPubTimelineUiState( items = emptyList(), initialShowIndex = 0, jumpToStatusId = null, showPagingLoadingPlaceholder = false, pageErrorContent = null, refreshing = false, loadMoreState = LoadState.Idle, ) } } sealed interface ActivityPubTimelineItem { data class StatusItem( val status: StatusUiState, ) : ActivityPubTimelineItem } fun List.updateStatus( status: StatusUiState, ): List { return map { if (it is ActivityPubTimelineItem.StatusItem && it.status.status.intrinsicBlog.id == status.status.intrinsicBlog.id) { ActivityPubTimelineItem.StatusItem(status) } else { it } } } fun List.getStatusIdOrNull(index: Int): String? { return this.getOrNull(index)?.let { when (it) { is ActivityPubTimelineItem.StatusItem -> it.status.status.id } } } fun List.getIndexByIdOrNull(id: String): Int { return this.indexOfFirst { when (it) { is ActivityPubTimelineItem.StatusItem -> it.status.status.id == id } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/explorer/ExplorerContainerTab.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.explorer import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.FreadTabRow import com.zhangke.framework.composable.LocalContentPadding import com.zhangke.framework.composable.plusTopPadding import com.zhangke.framework.nav.BaseTab import com.zhangke.framework.nav.TabOptions import com.zhangke.framework.utils.pxToDp import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.platform.BlogPlatform import kotlinx.coroutines.launch class ExplorerContainerTab( private val locator: PlatformLocator, private val platform: BlogPlatform, ) : BaseTab() { override val options: TabOptions? @Composable get() = null @Composable override fun Content() { super.Content() val tabs = remember { listOf( ExplorerTab( locator = locator, platform = platform, feedsTabType = ExplorerFeedsTabType.STATUS, ), ExplorerTab( locator = locator, platform = platform, feedsTabType = ExplorerFeedsTabType.HASHTAG, ), ExplorerTab( locator = locator, platform = platform, feedsTabType = ExplorerFeedsTabType.USERS, ), ) } val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() val pagerState = rememberPagerState(initialPage = 0) { tabs.size } var tabBarHeight by remember { mutableStateOf(24.dp) } CompositionLocalProvider( LocalContentPadding provides plusTopPadding(tabBarHeight), ) { HorizontalPager( modifier = Modifier.fillMaxSize(), state = pagerState, ) { pageIndex -> with(tabs[pageIndex]) { Content() } } } Column { Spacer( modifier = Modifier.fillMaxWidth() .padding(top = LocalContentPadding.current.calculateTopPadding()) ) FreadTabRow( modifier = Modifier.fillMaxWidth() .onSizeChanged { tabBarHeight = it.height.pxToDp(density) }, selectedTabIndex = pagerState.currentPage, tabCount = tabs.size, tabContent = { Text( text = tabs[it].options.title, maxLines = 1, ) }, onTabClick = { coroutineScope.launch { pagerState.scrollToPage(it) } }, ) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/explorer/ExplorerContainerViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.explorer import com.zhangke.framework.lifecycle.ContainerViewModel import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubAccountEntityAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubTagAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.platform.BlogPlatform class ExplorerContainerViewModel ( private val clientManager: ActivityPubClientManager, private val loggedAccountProvider: LoggedAccountProvider, private val activityPubStatusAdapter: ActivityPubStatusAdapter, private val accountAdapter: ActivityPubAccountEntityAdapter, private val hashtagAdapter: ActivityPubTagAdapter, private val statusProvider: StatusProvider, private val statusUpdater: StatusUpdater, private val statusUiStateAdapter: StatusUiStateAdapter, private val refactorToNewStatus: RefactorToNewStatusUseCase, ) : ContainerViewModel() { override fun createSubViewModel(params: Params): ExplorerViewModel { return ExplorerViewModel( clientManager = clientManager, loggedAccountProvider = loggedAccountProvider, activityPubStatusAdapter = activityPubStatusAdapter, accountAdapter = accountAdapter, hashtagAdapter = hashtagAdapter, statusProvider = statusProvider, statusUpdater = statusUpdater, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, locator = params.locator, platform = params.platform, type = params.type, ) } fun getViewModel( locator: PlatformLocator, platform: BlogPlatform, type: ExplorerFeedsTabType, ): ExplorerViewModel { return obtainSubViewModel(Params(locator, platform, type)) } class Params( val locator: PlatformLocator, val platform: BlogPlatform, val type: ExplorerFeedsTabType, ) : SubViewModelParams() { override val key: String get() = locator.toString() + platform + type } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/explorer/ExplorerFeedsTabType.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.explorer enum class ExplorerFeedsTabType { STATUS, HASHTAG, USERS, } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/explorer/ExplorerItem.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.explorer import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.Hashtag import com.zhangke.fread.status.model.StatusUiState sealed interface ExplorerItem { val id: String data class ExplorerStatus(val status: StatusUiState) : ExplorerItem { override val id: String get() = status.status.id } data class ExplorerHashtag(val hashtag: Hashtag) : ExplorerItem { override val id: String get() = hashtag.name } data class ExplorerUser(val user: BlogAuthor, val following: Boolean) : ExplorerItem { override val id: String get() = user.uri.toString() } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/explorer/ExplorerTab.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.explorer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.LocalSnackbarHostState import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.textString import com.zhangke.framework.controller.CommonLoadableUiState import com.zhangke.framework.loadable.lazycolumn.LoadableInlineVideoLazyColumn import com.zhangke.framework.loadable.lazycolumn.rememberLoadableInlineVideoLazyColumnState import com.zhangke.framework.nav.BaseTab import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.TabOptions import com.zhangke.fread.common.composable.ErrorContent import com.zhangke.fread.common.composable.ErrorType import com.zhangke.fread.commonbiz.shared.composable.FeedsStatusNode import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.ui.ComposedStatusInteraction import com.zhangke.fread.status.ui.RecommendAuthorUi import com.zhangke.fread.status.ui.StatusListPlaceholder import com.zhangke.fread.status.ui.common.ObserveScrollInProgressForConnection import com.zhangke.fread.status.ui.hashtag.HashtagUi import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel class ExplorerTab( private val locator: PlatformLocator, private val platform: BlogPlatform, private val feedsTabType: ExplorerFeedsTabType, ) : BaseTab() { override val options: TabOptions @Composable get() = TabOptions( title = when (feedsTabType) { ExplorerFeedsTabType.STATUS -> stringResource(LocalizedString.activity_pub_explorer_tab_status_title) ExplorerFeedsTabType.USERS -> stringResource(LocalizedString.activity_pub_explorer_tab_users_title) ExplorerFeedsTabType.HASHTAG -> stringResource(LocalizedString.activity_pub_explorer_tab_hashtag_title) } ) @Composable override fun Content() { super.Content() val backStack = LocalNavBackStack.currentOrThrow val viewModel = koinViewModel() .getViewModel(locator, platform, feedsTabType) val uiState by viewModel.uiState.collectAsState() val snackbarHostState = LocalSnackbarHostState.current ConsumeSnackbarFlow(snackbarHostState, viewModel.errorMessageFlow) ConsumeFlow(viewModel.openScreenFlow) { backStack.add(it) } ExplorerFeedsTabContent( uiState = uiState, onRefresh = viewModel::onRefresh, onLoadMore = viewModel::onLoadMore, composedStatusInteraction = viewModel.composedStatusInteraction, ) } @Composable private fun ExplorerFeedsTabContent( uiState: CommonLoadableUiState, onRefresh: () -> Unit, onLoadMore: () -> Unit, composedStatusInteraction: ComposedStatusInteraction, ) { val errorMessage = uiState.errorMessage?.let { textString(it) } var containerHeight: Dp? by remember { mutableStateOf(null) } val density = LocalDensity.current if (uiState.initializing) { StatusListPlaceholder() } else if (uiState.dataList.isEmpty()) { ErrorContent( modifier = Modifier.fillMaxSize(), type = ErrorType.Network, errorMessage = errorMessage, onRetryClick = onRefresh, ) } else { val state = rememberLoadableInlineVideoLazyColumnState( onRefresh = onRefresh, onLoadMore = onLoadMore, ) ObserveScrollInProgressForConnection(state.lazyListState) LoadableInlineVideoLazyColumn( modifier = Modifier.fillMaxSize(), state = state, refreshing = uiState.refreshing, loadState = uiState.loadMoreState, ) { itemsIndexed( items = uiState.dataList, ) { index, item -> ExplorerItemUi( modifier = Modifier.fillMaxWidth(), item = item, locator = locator, composedStatusInteraction = composedStatusInteraction, indexInList = index, ) } } } } @Composable private fun ExplorerItemUi( modifier: Modifier, item: ExplorerItem, locator: PlatformLocator, indexInList: Int, composedStatusInteraction: ComposedStatusInteraction, ) { when (item) { is ExplorerItem.ExplorerStatus -> { FeedsStatusNode( modifier = modifier, status = item.status, composedStatusInteraction = composedStatusInteraction, indexInList = indexInList, ) } is ExplorerItem.ExplorerUser -> { RecommendAuthorUi( modifier = modifier, locator = locator, author = item.user, following = item.following, composedStatusInteraction = composedStatusInteraction, ) } is ExplorerItem.ExplorerHashtag -> { HashtagUi( modifier = modifier, tag = item.hashtag, onClick = { composedStatusInteraction.onHashtagClick(locator, it) }, ) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/explorer/ExplorerUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.explorer class ExplorerUiState { } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/explorer/ExplorerViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.explorer import com.zhangke.framework.controller.CommonLoadableController import com.zhangke.framework.controller.CommonLoadableUiState import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.lifecycle.SubViewModel import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubAccountEntityAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubTagAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.feeds.IInteractiveHandler import com.zhangke.fread.commonbiz.shared.feeds.InteractiveHandleResult import com.zhangke.fread.commonbiz.shared.feeds.InteractiveHandler import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.platform.BlogPlatform import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update class ExplorerViewModel( private val clientManager: ActivityPubClientManager, private val loggedAccountProvider: LoggedAccountProvider, private val activityPubStatusAdapter: ActivityPubStatusAdapter, private val accountAdapter: ActivityPubAccountEntityAdapter, private val hashtagAdapter: ActivityPubTagAdapter, private val statusProvider: StatusProvider, statusUpdater: StatusUpdater, private val statusUiStateAdapter: StatusUiStateAdapter, refactorToNewStatus: RefactorToNewStatusUseCase, private val locator: PlatformLocator, private val platform: BlogPlatform, private val type: ExplorerFeedsTabType, ) : SubViewModel(), IInteractiveHandler by InteractiveHandler( statusProvider = statusProvider, statusUpdater = statusUpdater, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, ) { companion object { private const val LIMIT = 50 } private val loadController = CommonLoadableController( viewModelScope, onPostSnackMessage = { launchInViewModel { mutableErrorMessageFlow.emit(it) } }, ) val uiState: StateFlow> get() = loadController.uiState init { initInteractiveHandler( coroutineScope = viewModelScope, onInteractiveHandleResult = { interactiveResult -> when (interactiveResult) { is InteractiveHandleResult.UpdateStatus -> { loadController.mutableUiState.update { state -> val dataList = state.dataList.updateStatus(interactiveResult.status) state.copy(dataList = dataList) } } is InteractiveHandleResult.DeleteStatus -> { loadController.mutableUiState.update { state -> state.copy( dataList = state.dataList.filter { if (it is ExplorerItem.ExplorerStatus) { it.id != interactiveResult.statusId } else { true } } ) } } is InteractiveHandleResult.UpdateFollowState -> { val authorUri = interactiveResult.userUri loadController.mutableUiState.update { state -> val dataList = state.dataList.map { if (it is ExplorerItem.ExplorerUser && it.user.uri == authorUri) { it.copy(following = interactiveResult.following) } else { it } } state.copy(dataList = dataList) } } } }, ) launchInViewModel { loadController.initData( getDataFromServer = { getExplorer(maxId = null, offset = 0) }, getDataFromLocal = null, ) } } fun onRefresh() { loadController.onRefresh(false) { getExplorer(null, 0) } } fun onLoadMore() { val dataList = uiState.value.dataList if (dataList.isEmpty()) return loadController.onLoadMore { getExplorer( maxId = dataList.last().id, offset = loadController.uiState.value.dataList.size, ) } } private suspend fun getExplorer(maxId: String?, offset: Int): Result> { val client = clientManager.getClient(locator) return when (type) { ExplorerFeedsTabType.STATUS -> { val loggedAccount = locator.accountUri?.let { loggedAccountProvider.getAccount(it) } client.timelinesRepo .publicTimelines(limit = LIMIT, maxId = maxId) .map { list -> list.map { activityPubStatusAdapter.toStatusUiState( entity = it, platform = platform, locator = locator, loggedAccount = loggedAccount, ) } }.map { list -> list.map { ExplorerItem.ExplorerStatus(it) } } } ExplorerFeedsTabType.USERS -> { if ((offset > 0) || !maxId.isNullOrEmpty()) { return Result.success(emptyList()) } client.accountRepo .getSuggestions() .map { list -> list.map { accountAdapter.toAuthor(it.account) } } .map { list -> list.map { ExplorerItem.ExplorerUser(it, false) } } } ExplorerFeedsTabType.HASHTAG -> { client.instanceRepo .getTrendsTags(limit = LIMIT, offset = offset) .map { list -> list.map { hashtagAdapter.adapt(it) } } .map { list -> list.map { ExplorerItem.ExplorerHashtag(it) } } } } } private fun List.updateStatus(newStatus: StatusUiState): List { return map { item -> if (item is ExplorerItem.ExplorerStatus && item.status.status.intrinsicBlog.id == newStatus.status.intrinsicBlog.id) { item.copy(status = newStatus) } else { item } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/filters/edit/EditFilterScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.filters.edit import androidx.compose.foundation.clickable 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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Save import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.BackHandler import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.DatePickerDialog import com.zhangke.framework.composable.FreadDialog import com.zhangke.framework.composable.PopupMenu import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.rememberFutureDatePickerState import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.ui.utils.getScreenWidth import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.ExperimentalTime @Serializable data class EditFilterScreenKey( val locator: PlatformLocator, val id: String? = null, ) : NavKey @OptIn(ExperimentalComposeUiApi::class, ExperimentalTime::class) @Composable fun EditFilterScreen(viewModel: EditFilterViewModel, id: String?) { val backStack = LocalNavBackStack.currentOrThrow ConsumeFlow(HiddenKeywordScreenNavKey.keywordsListFlow.flow) { viewModel.onKeywordChanged(it) } val uiState by viewModel.uiState.collectAsState() val snackBarHostState = rememberSnackbarHostState() var showBackDialog by remember { mutableStateOf(false) } fun onBack() { if (uiState.hasInputtedSomething) { showBackDialog = true } else { backStack.removeLastOrNull() } } BackHandler(true) { onBack() } EditFilterContent( uiState = uiState, id = id, snackBarHostState = snackBarHostState, onBackClick = { onBack() }, onTitleChanged = viewModel::onTitleChanged, onExpiredDateSelected = viewModel::onExpiredDateSelected, onKeywordClick = { backStack.add(HiddenKeywordScreenNavKey(uiState.keywordList)) }, onContextChanged = viewModel::onContextChanged, onWarningCheckChanged = viewModel::onWarningCheckChanged, onDeleteClick = viewModel::onDeleteClick, onSubmitClick = viewModel::onSubmitClick, ) ConsumeSnackbarFlow(hostState = snackBarHostState, messageTextFlow = viewModel.snackBarFlow) ConsumeFlow(viewModel.finishPageFlow) { backStack.removeLastOrNull() } if (showBackDialog) { FreadDialog( onDismissRequest = { showBackDialog = false }, contentText = stringResource(LocalizedString.activity_pub_filter_edit_back_dialog), onNegativeClick = { showBackDialog = false }, onPositiveClick = { showBackDialog = false backStack.removeLastOrNull() } ) } } @OptIn(ExperimentalTime::class) @Composable private fun EditFilterContent( uiState: EditFilterUiState, id: String?, snackBarHostState: SnackbarHostState, onBackClick: () -> Unit, onTitleChanged: (String) -> Unit, onExpiredDateSelected: (Instant?) -> Unit, onKeywordClick: () -> Unit, onContextChanged: (List) -> Unit, onWarningCheckChanged: (Boolean) -> Unit, onDeleteClick: () -> Unit, onSubmitClick: () -> Unit, ) { Scaffold( topBar = { Toolbar( title = stringResource(LocalizedString.activity_pub_filter_edit_title), onBackClick = onBackClick, actions = { if (id.isNullOrEmpty().not()) { DeleteMenuItem(onDeleteClick = onDeleteClick) } SimpleIconButton( onClick = onSubmitClick, imageVector = Icons.Default.Save, contentDescription = "Save", ) }, ) }, snackbarHost = { SnackbarHost(hostState = snackBarHostState) }, ) { innerPadding -> Column( modifier = Modifier .fillMaxWidth() .padding(innerPadding), ) { OutlinedTextField( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, top = 24.dp, end = 16.dp), value = uiState.title, onValueChange = onTitleChanged, maxLines = 1, placeholder = { Text(text = stringResource(LocalizedString.activity_pub_filter_edit_input_title_label)) }, label = { Text(text = stringResource(LocalizedString.activity_pub_filter_edit_input_title_label)) }, ) DurationItem( uiState = uiState, onExpiredDateSelected = onExpiredDateSelected, ) LinedItem( modifier = Modifier .clickable { onKeywordClick() } .fillMaxWidth() .padding(horizontal = 16.dp), title = stringResource(LocalizedString.activity_pub_filter_edit_keyword_list_title), subtitle = stringResource( LocalizedString.activity_pub_filter_edit_keyword_list_desc, uiState.keywordCount ), ) ContextItem( uiState = uiState, onContextChanged = onContextChanged, ) WarningItem( uiState = uiState, onCheckChanged = onWarningCheckChanged, ) } } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class) @Composable private fun DurationItem( uiState: EditFilterUiState, onExpiredDateSelected: (Instant?) -> Unit, ) { val screenWidth = getScreenWidth() * 0.5F var showDurationPopup by remember { mutableStateOf(false) } var showDurationSelector by remember { mutableStateOf(false) } Box(modifier = Modifier.fillMaxWidth()) { LinedItem( modifier = Modifier .clickable { showDurationPopup = true } .fillMaxWidth() .padding(horizontal = 16.dp), title = stringResource(LocalizedString.activity_pub_filter_edit_duration), subtitle = uiState.getExpiresDateDesc(), ) PopupMenu( offset = DpOffset(screenWidth, 0.dp), expanded = showDurationPopup, onDismissRequest = { showDurationPopup = false }, ) { DropdownMenuItem( text = { Text(text = stringResource(LocalizedString.activity_pub_filter_edit_duration_permanent)) }, onClick = { showDurationPopup = false onExpiredDateSelected(null) }, ) DropdownMenuItem( text = { Text(text = stringResource(LocalizedString.activity_pub_filter_edit_duration_thirty_minutes)) }, onClick = { showDurationPopup = false onExpiredDateSelected(Clock.System.now().plus(30.minutes)) }, ) DropdownMenuItem( text = { Text(text = stringResource(LocalizedString.activity_pub_filter_edit_duration_one_hour)) }, onClick = { showDurationPopup = false onExpiredDateSelected(Clock.System.now().plus(1.hours)) }, ) DropdownMenuItem( text = { Text(text = stringResource(LocalizedString.activity_pub_filter_edit_duration_twelve_hours)) }, onClick = { showDurationPopup = false onExpiredDateSelected(Clock.System.now().plus(12.hours)) }, ) DropdownMenuItem( text = { Text(text = stringResource(LocalizedString.activity_pub_filter_edit_duration_one_day)) }, onClick = { showDurationPopup = false onExpiredDateSelected(Clock.System.now().plus(1.days)) }, ) DropdownMenuItem( text = { Text(text = stringResource(LocalizedString.activity_pub_filter_edit_duration_three_day)) }, onClick = { showDurationPopup = false onExpiredDateSelected(Clock.System.now().plus(3.days)) }, ) DropdownMenuItem( text = { Text(text = stringResource(LocalizedString.activity_pub_filter_edit_duration_one_week)) }, onClick = { showDurationPopup = false onExpiredDateSelected(Clock.System.now().plus(7.days)) }, ) DropdownMenuItem( text = { Text(text = stringResource(LocalizedString.activity_pub_filter_edit_duration_custom)) }, onClick = { showDurationPopup = false showDurationSelector = true }, ) } val pickerState = rememberFutureDatePickerState( initialSelectedDateMillis = uiState.expiresDate?.toEpochMilliseconds() ) DatePickerDialog( datePickerState = pickerState, visible = showDurationSelector, onDismissRequest = { showDurationSelector = false }, onConfirmClick = { showDurationSelector = false pickerState.selectedDateMillis ?.let { Instant.fromEpochMilliseconds(it) } ?.let(onExpiredDateSelected) }, ) } } @Composable private fun WarningItem( uiState: EditFilterUiState, onCheckChanged: (Boolean) -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { LinedItem( modifier = Modifier.weight(1F), title = stringResource(LocalizedString.activity_pub_filter_edit_warning_title), subtitle = stringResource(LocalizedString.activity_pub_filter_edit_warning_desc), ) Switch( checked = uiState.filterByWarn, onCheckedChange = { onCheckChanged(it) }, ) } } @Composable private fun ContextItem( uiState: EditFilterUiState, onContextChanged: (List) -> Unit, ) { var showSelector by remember { mutableStateOf(false) } LinedItem( modifier = Modifier .clickable { showSelector = true } .fillMaxWidth() .padding(horizontal = 16.dp), title = stringResource(LocalizedString.activity_pub_filter_edit_context_title), subtitle = uiState.contextList.map { it.title }.joinToString().ifEmpty { stringResource(LocalizedString.activity_pub_filter_edit_empty_context) }, ) if (showSelector) { ContextSelector( selectedContext = uiState.contextList, onContextSelected = onContextChanged, onDismissRequest = { showSelector = false }, ) } } @Composable private fun ContextSelector( selectedContext: List, onContextSelected: (List) -> Unit, onDismissRequest: () -> Unit, ) { val currentSelected = remember(selectedContext) { mutableStateListOf().also { it.addAll(selectedContext) } } FreadDialog( title = stringResource(LocalizedString.activity_pub_filter_edit_context_selector_title), onDismissRequest = onDismissRequest, onNegativeClick = onDismissRequest, content = { Column { FilterContext.entries.forEach { context -> val selected = currentSelected.contains(context) Box( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp, horizontal = 16.dp), ) { Text( modifier = Modifier.align(Alignment.CenterStart), text = context.title, style = MaterialTheme.typography.titleMedium, ) Checkbox( modifier = Modifier.align(Alignment.CenterEnd), checked = selected, onCheckedChange = { if (it) { currentSelected += context } else { currentSelected -= context } }, ) } } } }, onPositiveClick = { onDismissRequest() onContextSelected(currentSelected) }, ) } @Composable private fun LinedItem( modifier: Modifier, title: String, subtitle: String, ) { Column( modifier = modifier, ) { Spacer(modifier = Modifier.height(16.dp)) Text( text = title, style = MaterialTheme.typography.titleMedium, ) Text( modifier = Modifier.padding(top = 4.dp), text = subtitle, style = MaterialTheme.typography.bodyMedium, ) Spacer(modifier = Modifier.height(16.dp)) } } @Composable private fun DeleteMenuItem(onDeleteClick: () -> Unit) { var showConfirmDialog by remember { mutableStateOf(false) } SimpleIconButton( onClick = { showConfirmDialog = true }, imageVector = Icons.Default.Delete, contentDescription = "Delete" ) if (showConfirmDialog) { FreadDialog( contentText = stringResource(LocalizedString.activity_pub_filter_edit_delete_content), onDismissRequest = { showConfirmDialog = false }, onNegativeClick = { showConfirmDialog = false }, onPositiveClick = { showConfirmDialog = false onDeleteClick() }, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/filters/edit/EditFilterUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.filters.edit import androidx.compose.runtime.Composable import com.zhangke.framework.utils.Parcelize import com.zhangke.framework.utils.PlatformParcelable import com.zhangke.framework.utils.PlatformSerializable import com.zhangke.fread.common.utils.formatDefault import com.zhangke.fread.localization.LocalizedString import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource import kotlin.time.ExperimentalTime import kotlinx.datetime.Instant data class EditFilterUiState @OptIn(ExperimentalTime::class) constructor( val title: String, val expiresDate: Instant?, val keywordList: List, val contextList: List, val filterByWarn: Boolean, val hasInputtedSomething: Boolean, ) { val keywordCount: Int get() = keywordList.filter { !it.deleted }.size @OptIn(ExperimentalTime::class) private val expiresDateString: String by lazy { if (expiresDate == null) return@lazy "" // val dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()) // val timeFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.getDefault()) // dateFormat.format(expiresDate) + " " + timeFormat.format(expiresDate) expiresDate.formatDefault() } @OptIn(ExperimentalTime::class) @Composable fun getExpiresDateDesc(): String { if (expiresDate == null) { return stringResource(LocalizedString.activity_pub_filter_edit_duration_permanent) } return stringResource( LocalizedString.activity_pub_filter_edit_duration_finish_subtitle, expiresDateString, ) } @Parcelize @Serializable data class Keyword( val keyword: String, val id: String? = null, val deleted: Boolean = false, val wholeWord: Boolean = true, ) : PlatformSerializable, PlatformParcelable companion object { @OptIn(ExperimentalTime::class) fun default(): EditFilterUiState { return EditFilterUiState( title = "", expiresDate = null, keywordList = emptyList(), contextList = FilterContext.entries, filterByWarn = true, hasInputtedSomething = false, ) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/filters/edit/EditFilterViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.filters.edit import androidx.lifecycle.ViewModel import com.zhangke.activitypub.entities.ActivityPubCreateFilterEntity import com.zhangke.activitypub.entities.ActivityPubFilterEntity import com.zhangke.activitypub.entities.ActivityPubFilterKeywordEntity import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.date.DateParser import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.common.utils.getCurrentTimeMillis import com.zhangke.fread.status.model.PlatformLocator import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlin.time.ExperimentalTime import kotlinx.datetime.Instant class EditFilterViewModel( private val clientManager: ActivityPubClientManager, private val locator: PlatformLocator, private val id: String?, ) : ViewModel() { private val _uiState = MutableStateFlow(EditFilterUiState.default()) val uiState: StateFlow = _uiState private val _snackBarFlow = MutableSharedFlow() val snackBarFlow: SharedFlow = _snackBarFlow private val _finishPageFlow = MutableSharedFlow() val finishPageFlow: SharedFlow = _finishPageFlow private var submitJob: Job? = null init { loadFilter() } fun onTitleChanged(title: String) { _uiState.update { it.copy(title = title, hasInputtedSomething = true) } } @OptIn(ExperimentalTime::class) fun onExpiredDateSelected(date: Instant?) { _uiState.update { it.copy(expiresDate = date, hasInputtedSomething = true) } } fun onKeywordChanged(keywordList: List) { _uiState.update { it.copy(keywordList = keywordList, hasInputtedSomething = true) } } fun onContextChanged(contextList: List) { _uiState.update { it.copy(contextList = contextList, hasInputtedSomething = true) } } fun onWarningCheckChanged(checked: Boolean) { _uiState.update { it.copy(filterByWarn = checked, hasInputtedSomething = true) } } fun onDeleteClick() { val filterId = id ?: return launchInViewModel { clientManager.getClient(locator).accountRepo .deleteFilter(filterId) .onSuccess { _finishPageFlow.emit(Unit) }.onFailure { _snackBarFlow.emitTextMessageFromThrowable(it) } } } fun onSubmitClick() { if (submitJob?.isActive == true) return submitJob = launchInViewModel { val request = _uiState.value.toRequest() val accountRepo = clientManager.getClient(locator).accountRepo if (id.isNullOrEmpty()) { accountRepo.createFilters(request) } else { accountRepo.updateFilters(id, request) }.onSuccess { _finishPageFlow.emit(Unit) }.onFailure { _snackBarFlow.emitTextMessageFromThrowable(it) } } } private fun EditFilterUiState.toRequest(): ActivityPubCreateFilterEntity { val expiresIn = if (expiresDate == null) { null } else { (expiresDate.toEpochMilliseconds() - getCurrentTimeMillis()) / 1000 } return ActivityPubCreateFilterEntity( title = this.title, context = this.contextList.map { it.contextName }, filterAction = if (this.filterByWarn) { ActivityPubFilterEntity.FILTER_ACTION_WARN } else { ActivityPubFilterEntity.FILTER_ACTION_KEYWORDS }, expiresIn = expiresIn?.toInt()?.coerceAtLeast(0), keywordsAttributes = this.keywordList.map { keyword -> ActivityPubCreateFilterEntity.KeywordAttribute( id = keyword.id, keyword = keyword.keyword.trim(), wholeWord = keyword.wholeWord, destroy = keyword.deleted, ) }, ) } private fun loadFilter() { if (id != null) { launchInViewModel { clientManager.getClient(locator) .accountRepo .getFilter(id) .onFailure { _snackBarFlow.emitTextMessageFromThrowable(it) }.onSuccess { _uiState.value = it.toFilter() } } } } private fun ActivityPubFilterEntity.toFilter(): EditFilterUiState { return EditFilterUiState( title = this.title, expiresDate = expiresAt?.let(DateParser::parseAll), keywordList = this.keywords?.map { it.toKeyword() } ?: emptyList(), contextList = this.context.mapNotNull { FilterContext.fromContext(it) }, filterByWarn = this.filterAction == ActivityPubFilterEntity.FILTER_ACTION_WARN, hasInputtedSomething = false, ) } private fun ActivityPubFilterKeywordEntity.toKeyword(): EditFilterUiState.Keyword { return EditFilterUiState.Keyword( id = this.id, keyword = this.keyword, wholeWord = this.wholeWord, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/filters/edit/FilterContext.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.filters.edit import androidx.compose.runtime.Composable import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.stringResource enum class FilterContext(val contextName: String) { HOME_LIST("home"), NOTIFICATION("notifications"), TIMELINE("public"), POST_REPLY("thread"), USER_INFO("account"); val title: String @Composable get() = when (this) { HOME_LIST -> stringResource(LocalizedString.activity_pub_filter_edit_context_home) NOTIFICATION -> stringResource(LocalizedString.activity_pub_filter_edit_context_notification) TIMELINE -> stringResource(LocalizedString.activity_pub_filter_edit_context_timeline) POST_REPLY -> stringResource(LocalizedString.activity_pub_filter_edit_context_thread) USER_INFO -> stringResource(LocalizedString.activity_pub_filter_edit_context_account) } companion object { fun fromContext(context: String): FilterContext? { return FilterContext.entries.firstOrNull { it.contextName == context } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/filters/edit/HiddenKeywordScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.filters.edit import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.Checkbox import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import com.zhangke.framework.composable.BackHandler import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.FreadDialog import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.ScreenEventFlow import com.zhangke.fread.localization.LocalizedString import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable data class HiddenKeywordScreenNavKey( val addedKeywords: List, ) : NavKey { companion object { val keywordsListFlow = ScreenEventFlow>() } } @OptIn(ExperimentalComposeUiApi::class) @Composable fun HiddenKeywordScreen(addedKeywords: List) { val backStack = LocalNavBackStack.currentOrThrow val keywordsList = remember(addedKeywords) { mutableStateListOf().also { it.addAll(addedKeywords) } } val showingKeywordList = keywordsList.filter { !it.deleted } var pendingEditKeyword: EditFilterUiState.Keyword? by remember { mutableStateOf(null) } var showEditDialog by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() BackHandler(true) { coroutineScope.launch { HiddenKeywordScreenNavKey.keywordsListFlow.emit(keywordsList) backStack.removeLastOrNull() } } Scaffold( topBar = { Toolbar( title = stringResource(LocalizedString.activity_pub_filter_edit_keyword_title), onBackClick = { coroutineScope.launch { HiddenKeywordScreenNavKey.keywordsListFlow.emit(keywordsList) backStack.removeLastOrNull() } }, ) }, floatingActionButton = { FloatingActionButton( containerColor = MaterialTheme.colorScheme.surface, onClick = { pendingEditKeyword = null showEditDialog = true }, ) { Icon(imageVector = Icons.Default.Add, contentDescription = "Add") } }, ) { innerPadding -> LazyColumn( modifier = Modifier .padding(innerPadding) .fillMaxSize(), ) { items(showingKeywordList) { keyword -> Row( modifier = Modifier.fillMaxWidth() .padding(start = 16.dp, top = 16.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Column( modifier = Modifier.weight(1f), ) { Text( modifier = Modifier.fillMaxWidth(), text = keyword.keyword, textAlign = TextAlign.Start, style = MaterialTheme.typography.titleMedium, ) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Checkbox( checked = keyword.wholeWord, onCheckedChange = { keywordsList[keywordsList.indexOf(keyword)] = keyword.copy(wholeWord = it) }, ) Text( text = stringResource(LocalizedString.activity_pub_filter_edit_whole_word), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } Spacer(modifier = Modifier.width(8.dp)) var showDeleteConfirmDialog by remember { mutableStateOf(false) } SimpleIconButton( modifier = Modifier, onClick = { showDeleteConfirmDialog = true }, imageVector = Icons.Default.Delete, contentDescription = "Delete", ) if (showDeleteConfirmDialog) { FreadDialog( contentText = stringResource(LocalizedString.activity_pub_filter_edit_keyword_remove_keyword_dialog_content), onDismissRequest = { showDeleteConfirmDialog = false }, onNegativeClick = { showDeleteConfirmDialog = false }, onPositiveClick = { showDeleteConfirmDialog = false keywordsList[keywordsList.indexOf(keyword)] = keyword.copy(deleted = true) }, ) } } } } } if (showEditDialog) { EditKeywordDialog( keyword = pendingEditKeyword, onConfirmClick = { if (pendingEditKeyword == null) { keywordsList.add(EditFilterUiState.Keyword(keyword = it)) } else { if (keywordsList.contains(pendingEditKeyword)) { keywordsList[keywordsList.indexOf(pendingEditKeyword)] = pendingEditKeyword!!.copy(keyword = it) } } pendingEditKeyword = null }, onDismissRequest = { showEditDialog = false }, ) } } @Composable private fun EditKeywordDialog( keyword: EditFilterUiState.Keyword?, onConfirmClick: (String) -> Unit, onDismissRequest: () -> Unit, ) { var inputtingKeyword by remember(keyword) { mutableStateOf(keyword?.keyword.orEmpty()) } FreadDialog( title = stringResource(LocalizedString.activity_pub_filter_edit_keyword_dialog_title), onDismissRequest = onDismissRequest, content = { OutlinedTextField( modifier = Modifier .padding(16.dp) .fillMaxWidth(), value = inputtingKeyword, onValueChange = { inputtingKeyword = it }, maxLines = 1, label = { Text(text = stringResource(LocalizedString.activity_pub_filter_edit_keyword_dialog_hint)) }, placeholder = { Text(text = stringResource(LocalizedString.activity_pub_filter_edit_keyword_dialog_hint)) }, ) }, onNegativeClick = onDismissRequest, onPositiveClick = { onDismissRequest() if (inputtingKeyword.isNotEmpty()) { onConfirmClick(inputtingKeyword) } }, ) } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/filters/list/FiltersListScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.filters.list import androidx.compose.foundation.clickable 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.freadPlaceholder import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.composable.textString import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.activitypub.app.internal.screen.filters.edit.EditFilterScreenKey import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable data class FiltersListScreenKey(val locator: PlatformLocator) : NavKey @Composable fun FiltersListScreen(viewModel: FiltersListViewModel, locator: PlatformLocator) { val backStack = LocalNavBackStack.currentOrThrow val uiState by viewModel.uiState.collectAsState() val snackBarHostState = rememberSnackbarHostState() FiltersListContent( uiState = uiState, snackBarHostState = snackBarHostState, onBackClick = backStack::removeLastOrNull, onItemClick = { backStack.add(EditFilterScreenKey(locator, it.id)) }, onAddClick = { backStack.add(EditFilterScreenKey(locator, null)) }, ) LaunchedEffect(Unit) { viewModel.onPageResume() } ConsumeSnackbarFlow(snackBarHostState, viewModel.snackBarFlow) } @Composable private fun FiltersListContent( uiState: FiltersListUiState, snackBarHostState: SnackbarHostState, onBackClick: () -> Unit, onItemClick: (FilterItemUiState) -> Unit, onAddClick: () -> Unit, ) { Scaffold( snackbarHost = { SnackbarHost(snackBarHostState) }, topBar = { Toolbar( title = stringResource(LocalizedString.activity_pub_filters_list_page_title), onBackClick = onBackClick, ) }, floatingActionButton = { FloatingActionButton( containerColor = MaterialTheme.colorScheme.surface, onClick = { onAddClick() }, ) { Icon(imageVector = Icons.Default.Add, contentDescription = "Add") } }, ) { innerPadding -> val state = rememberLazyListState() LazyColumn( modifier = Modifier .fillMaxSize() .padding(innerPadding), state = state, ) { if (uiState.initializing && uiState.list.isEmpty()) { items(30) { FilterItemPlaceholder() } } else if (uiState.list.isNotEmpty()) { items(uiState.list) { FilterItem( filterEntity = it, onItemClick = onItemClick, ) } } } } } @Composable private fun FilterItemPlaceholder() { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp) ) { Box( modifier = Modifier .size(width = 100.dp, height = 18.dp) .freadPlaceholder(true) ) Box( modifier = Modifier .padding(top = 4.dp) .size(width = 180.dp, height = 16.dp) .freadPlaceholder(true) ) } } @Composable private fun FilterItem( filterEntity: FilterItemUiState, onItemClick: (FilterItemUiState) -> Unit, ) { Column( modifier = Modifier .clickable { onItemClick(filterEntity) } .fillMaxWidth() .padding(16.dp) ) { Text( text = filterEntity.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( modifier = Modifier.padding(top = 4.dp), text = textString(text = filterEntity.validateDescription), style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/filters/list/FiltersListUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.filters.list import com.zhangke.framework.composable.TextString data class FiltersListUiState( val initializing: Boolean, val list: List, ) { companion object { fun default(): FiltersListUiState { return FiltersListUiState( initializing = false, list = emptyList(), ) } } } data class FilterItemUiState( val id: String, val title: String, val validateDescription: TextString, ) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/filters/list/FiltersListViewModel.kt ================================================ @file:OptIn(ExperimentalTime::class) package com.zhangke.fread.activitypub.app.internal.screen.filters.list import androidx.lifecycle.ViewModel import com.zhangke.activitypub.entities.ActivityPubFilterEntity import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.composable.textOf import com.zhangke.framework.date.DateParser import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.common.utils.getCurrentTimeMillis import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlin.time.ExperimentalTime class FiltersListViewModel ( private val clientManager: ActivityPubClientManager, private val locator: PlatformLocator, ) : ViewModel() { private val _uiState = MutableStateFlow(FiltersListUiState.default()) val uiState: StateFlow = _uiState private val _snackBarFlow = MutableSharedFlow() val snackBarFlow: SharedFlow = _snackBarFlow init { launchInViewModel { _uiState.update { it.copy(initializing = true) } fetchFilters() .onSuccess { list -> _uiState.update { it.copy(initializing = false, list = list) } }.onFailure { t -> _uiState.update { it.copy(initializing = false) } _snackBarFlow.emitTextMessageFromThrowable(t) } } } fun onPageResume() { launchInViewModel { fetchFilters() .onSuccess { list -> _uiState.update { it.copy(list = list) } }.onFailure { t -> _snackBarFlow.emitTextMessageFromThrowable(t) } } } private suspend fun fetchFilters(): Result> { return clientManager.getClient(locator) .accountRepo .getFilters() .map { list -> list.map { it.toUiState() } } } private fun ActivityPubFilterEntity.toUiState(): FilterItemUiState { val expiresAtDate = this.expiresAt?.let(DateParser::parseAll) val validateDescription = if (expiresAtDate != null && expiresAtDate.toEpochMilliseconds() < getCurrentTimeMillis()){ textOf(LocalizedString.activity_pub_filters_expired) }else{ textOf(LocalizedString.activity_pub_filters_active) } return FilterItemUiState( id = this.id, title = this.title, validateDescription = validateDescription, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/hashtag/HashtagTimelineContainerViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.hashtag import com.zhangke.framework.lifecycle.ContainerViewModel import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.PlatformLocator class HashtagTimelineContainerViewModel ( private val clientManager: ActivityPubClientManager, private val statusProvider: StatusProvider, private val statusUpdater: StatusUpdater, private val statusAdapter: ActivityPubStatusAdapter, private val platformRepo: ActivityPubPlatformRepo, private val statusUiStateAdapter: StatusUiStateAdapter, private val refactorToNewStatus: RefactorToNewStatusUseCase, private val loggedAccountProvider: LoggedAccountProvider, ) : ContainerViewModel() { override fun createSubViewModel(params: Params): HashtagTimelineViewModel { return HashtagTimelineViewModel( clientManager = clientManager, statusProvider = statusProvider, statusUpdater = statusUpdater, statusAdapter = statusAdapter, platformRepo = platformRepo, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, locator = params.locator, hashtag = params.tag, loggedAccountProvider = loggedAccountProvider, ) } fun getViewModel(locator: PlatformLocator, tag: String): HashtagTimelineViewModel { val params = Params(locator, tag) return obtainSubViewModel(params) } class Params( val locator: PlatformLocator, val tag: String, ) : SubViewModelParams() { override val key: String get() = locator.toString() + tag } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/hashtag/HashtagTimelineScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.hashtag import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.blur.BlurController import com.zhangke.framework.blur.LocalBlurController import com.zhangke.framework.blur.applyBlurEffect import com.zhangke.framework.blur.blurEffectContainerColor import com.zhangke.framework.blur.rememberBlurController import com.zhangke.framework.composable.AlertConfirmDialog import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.LocalContentPadding import com.zhangke.framework.composable.LocalSnackbarHostState import com.zhangke.framework.composable.SingleRowTopAppBar import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.TopAppBarColors import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.plusContentPadding import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.commonbiz.shared.composable.FeedsContent import com.zhangke.fread.commonbiz.shared.feeds.CommonFeedsUiState import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.ui.ComposedStatusInteraction import kotlinx.coroutines.flow.SharedFlow import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable data class HashtagTimelineScreenKey( val locator: PlatformLocator, val hashtag: String, ) : NavKey @Composable fun HashtagTimelineScreen( viewModel: HashtagTimelineContainerViewModel, locator: PlatformLocator, hashtag: String, ) { val backStack = LocalNavBackStack.currentOrThrow val viewModel = viewModel.getViewModel(locator, hashtag) val hashtagTimelineUiState by viewModel.hashtagTimelineUiState.collectAsState() val statusUiState by viewModel.uiState.collectAsState() HashtagTimelineContent( hashtagTimelineUiState = hashtagTimelineUiState, statusUiState = statusUiState, messageFlow = viewModel.errorMessageFlow, openScreenFlow = viewModel.openScreenFlow, newStatusNotifyFlow = viewModel.newStatusNotifyFlow, onBackClick = backStack::removeLastOrNull, onRefresh = viewModel::onRefresh, onLoadMore = viewModel::onLoadMore, composedStatusInteraction = viewModel.composedStatusInteraction, onFollowClick = viewModel::onFollowClick, onUnfollowClick = viewModel::onUnfollowClick, ) ConsumeSnackbarFlow(LocalSnackbarHostState.current, viewModel.errorMessageFlow) } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun HashtagTimelineContent( hashtagTimelineUiState: HashtagTimelineUiState, statusUiState: CommonFeedsUiState, messageFlow: SharedFlow, openScreenFlow: SharedFlow, newStatusNotifyFlow: SharedFlow, onBackClick: () -> Unit, onRefresh: () -> Unit, onLoadMore: () -> Unit, composedStatusInteraction: ComposedStatusInteraction, onFollowClick: () -> Unit, onUnfollowClick: () -> Unit, ) { val snackbarHostState = rememberSnackbarHostState() val topBarColor = MaterialTheme.colorScheme.surface val blurController = rememberBlurController() CompositionLocalProvider( LocalBlurController provides blurController ) { Scaffold( modifier = Modifier, snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { SingleRowTopAppBar( modifier = Modifier.fillMaxWidth() .applyBlurEffect(containerColor = topBarColor), colors = TopAppBarColors.default( containerColor = blurEffectContainerColor(containerColor = topBarColor), ), title = { Column { Text( hashtagTimelineUiState.hashTag, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( modifier = Modifier .padding(top = 2.dp), text = hashtagTimelineUiState.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, navigationIcon = { Toolbar.BackButton(onBackClick = onBackClick) }, actions = { FollowHashtagButton( modifier = Modifier.padding(end = 8.dp), uiState = hashtagTimelineUiState, onFollowClick = onFollowClick, onUnfollowClick = onUnfollowClick, ) }, ) }, ) { innerPadding -> CompositionLocalProvider( LocalContentPadding provides plusContentPadding(innerPadding), ) { FeedsContent( uiState = statusUiState, openScreenFlow = openScreenFlow, newStatusNotifyFlow = newStatusNotifyFlow, composedStatusInteraction = composedStatusInteraction, onRefresh = onRefresh, onLoadMore = onLoadMore, nestedScrollConnection = null, ) } } } ConsumeSnackbarFlow(snackbarHostState, messageFlow) } @Composable private fun FollowHashtagButton( modifier: Modifier, uiState: HashtagTimelineUiState, onFollowClick: () -> Unit, onUnfollowClick: () -> Unit, ) { var showUnfollowDialog by remember { mutableStateOf(false) } val containerColor = if (uiState.following) { MaterialTheme.colorScheme.secondaryContainer } else { MaterialTheme.colorScheme.primaryContainer } FilledTonalButton( modifier = modifier, onClick = { if (uiState.following) { showUnfollowDialog = true } else { onFollowClick() } }, colors = ButtonDefaults.outlinedButtonColors( containerColor = containerColor, contentColor = MaterialTheme.colorScheme.contentColorFor(containerColor), ), ) { Text( text = if (uiState.following) { stringResource(LocalizedString.statusUiUserDetailRelationshipFollowing) } else { stringResource(LocalizedString.statusUiUserDetailRelationshipNotFollow) }, ) } if (showUnfollowDialog) { AlertConfirmDialog( content = stringResource(LocalizedString.activity_pub_hashtag_unfollow_dialog_message), onConfirm = onUnfollowClick, onDismissRequest = { showUnfollowDialog = false }, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/hashtag/HashtagTimelineUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.hashtag import com.zhangke.fread.status.model.PlatformLocator data class HashtagTimelineUiState( val locator: PlatformLocator, val hashTag: String, val following: Boolean, val description: String, ) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/hashtag/HashtagTimelineViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.hashtag import com.zhangke.activitypub.entities.ActivityPubTagEntity import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.lifecycle.SubViewModel import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.feeds.model.RefreshResult import com.zhangke.fread.common.status.StatusConfigurationDefault import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.common.utils.getCurrentInstant import com.zhangke.fread.commonbiz.shared.feeds.FeedsViewModelController import com.zhangke.fread.commonbiz.shared.feeds.IFeedsViewModelController import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.getString import kotlin.time.ExperimentalTime class HashtagTimelineViewModel( private val clientManager: ActivityPubClientManager, private val statusProvider: StatusProvider, private val statusUpdater: StatusUpdater, private val statusAdapter: ActivityPubStatusAdapter, private val platformRepo: ActivityPubPlatformRepo, private val loggedAccountProvider: LoggedAccountProvider, statusUiStateAdapter: StatusUiStateAdapter, refactorToNewStatus: RefactorToNewStatusUseCase, private val locator: PlatformLocator, private val hashtag: String, ) : SubViewModel(), IFeedsViewModelController by FeedsViewModelController( statusProvider = statusProvider, statusUpdater = statusUpdater, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, ) { private val _hashtagTimelineUiState = MutableStateFlow( HashtagTimelineUiState( locator = locator, hashTag = hashtag, following = false, description = "", ) ) val hashtagTimelineUiState = _hashtagTimelineUiState.asStateFlow() init { initController( coroutineScope = viewModelScope, locatorResolver = { locator }, loadFirstPageLocalFeeds = { Result.success(emptyList()) }, loadNewFromServerFunction = ::loadNewFromServer, loadMoreFunction = ::loadMore, onStatusUpdate = {}, ) initFeeds(false) launchInViewModel { clientManager.getClient(locator) .accountRepo .getTagInformation(hashtag) .onSuccess { _hashtagTimelineUiState.value = _hashtagTimelineUiState.value.copy( following = it.following, description = buildDescription(it), ) } } } private suspend fun loadNewFromServer(): Result { return loadHashtagTimeline().map { RefreshResult( newStatus = it, deletedStatus = emptyList(), ) } } private suspend fun loadMore(maxId: String?): Result> { return loadHashtagTimeline(maxId) } private suspend fun buildDescription(hashTag: ActivityPubTagEntity): String { val todayTimeInMillis = getTodayTimeInMillis() var posts = 0 var participants = 0 var todayPosts = 0 hashTag.history.forEach { posts += it.uses participants += it.accounts if ((it.day * 1000) >= todayTimeInMillis) { todayPosts += it.uses } } return getString( LocalizedString.activity_pub_hashtag_timeline_description, posts.toString(), participants.toString(), todayPosts.toString(), ) } @OptIn(ExperimentalTime::class) private fun getTodayTimeInMillis(): Long { val timeZone = TimeZone.currentSystemDefault() val today = getCurrentInstant().toLocalDateTime(timeZone) return LocalDateTime(today.year, today.month, today.dayOfMonth, 0, 0, 0) .toInstant(timeZone) .toEpochMilliseconds() } fun onFollowClick() { launchInViewModel { clientManager.getClient(locator) .accountRepo .followTag(hashtag) .handle() } } fun onUnfollowClick() { launchInViewModel { clientManager.getClient(locator) .accountRepo .unfollowTag(hashtag) .handle() } } private suspend fun Result.handle() { this.onSuccess { newEntity -> _hashtagTimelineUiState.update { state -> state.copy( following = newEntity.following, description = buildDescription(newEntity), ) } }.onFailure { e -> viewModelScope.launch { mutableErrorMessageFlow.emitTextMessageFromThrowable(e) } } } private suspend fun loadHashtagTimeline(maxId: String? = null): Result> { val platformResult = platformRepo.getPlatform(locator) if (platformResult.isFailure) { return Result.failure(platformResult.exceptionOrNull()!!) } val platform = platformResult.getOrThrow() val account = locator.accountUri?.let { loggedAccountProvider.getAccount(it) } return clientManager.getClient(locator) .timelinesRepo .getTagTimeline( hashtag = hashtag, limit = StatusConfigurationDefault.config.loadFromServerLimit, maxId = maxId, ).map { list -> list.map { statusAdapter.toStatusUiState( entity = it, platform = platform, locator = locator, loggedAccount = account, ) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/instance/InstanceDetailScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.instance import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation3.runtime.NavKey import com.seiko.imageloader.ui.AutoSizeImage import com.zhangke.framework.composable.PopupMenu import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.freadPlaceholder import com.zhangke.framework.composable.noRippleClick import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.ContentPaddingsHorizontalPagerWithTab import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.activitypub.app.internal.screen.instance.about.ServerAboutTab import com.zhangke.fread.activitypub.app.internal.screen.instance.tags.ServerTrendsTagsTab import com.zhangke.fread.activitypub.app.internal.screen.user.UserDetailScreenKey import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.common.handler.LocalTextHandler import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.richtext.buildRichText import com.zhangke.fread.status.ui.action.DropDownCopyLinkItem import com.zhangke.fread.status.ui.action.DropDownOpenInBrowserItem import com.zhangke.fread.status.ui.common.DetailPageScaffold import com.zhangke.fread.status.ui.richtext.FreadRichText import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable data class InstanceDetailScreenKey( val locator: PlatformLocator, val baseUrl: FormalBaseUrl, ) : NavKey @Composable fun InstanceDetailScreen(locator: PlatformLocator, viewModel: InstanceDetailViewModel) { val uiState by viewModel.uiState.collectAsState() val backStack = LocalNavBackStack.currentOrThrow InstanceDetailContent( uiState = uiState, locator = locator, onBackClick = { backStack.removeLastOrNull() }, onUserClick = { _, webFinger, userId -> backStack.add( UserDetailScreenKey( locator = locator, webFinger = webFinger, userId = userId, ) ) } ) } @Composable private fun InstanceDetailContent( uiState: InstanceDetailUiState, locator: PlatformLocator, onBackClick: () -> Unit, onUserClick: (PlatformLocator, WebFinger, String?) -> Unit, ) { val browserLauncher = LocalActivityBrowserLauncher.current val contentCanScrollBackward = remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() DetailPageScaffold( modifier = Modifier.fillMaxSize(), snackbarHostState = rememberSnackbarHostState(), title = buildRichText(uiState.instance?.title.orEmpty()), loading = uiState.loading, avatar = uiState.instance?.thumbnail?.url.orEmpty(), banner = uiState.instance?.thumbnail?.url, privateNote = null, topBarActions = { val baseUrl = uiState.baseUrl InstanceDetailActions(baseUrl) }, contentCanScrollBackward = contentCanScrollBackward, description = buildRichText(uiState.instance?.description.orEmpty()), handleLine = { Text( text = uiState.baseUrl.toString(), style = MaterialTheme.typography.labelMedium, ) }, followInfoLine = { InstanceModLine( uiState = uiState, onUserClick = onUserClick, ) }, onBackClick = onBackClick, onUrlClick = { coroutineScope.launch { browserLauncher.launchWebTabInApp(it, locator) } }, onMaybeHashtagClick = {}, onBannerClick = {}, onAvatarClick = {}, topDetailContentAction = null, bottomArea = null, ) { progress -> if (uiState.instance != null) { val tabs = remember(uiState) { listOf( ServerAboutTab( baseUrl = uiState.baseUrl, rules = uiState.instance.rules, contentCanScrollBackward = contentCanScrollBackward, ), ServerTrendsTagsTab( baseUrl = uiState.baseUrl, contentCanScrollBackward = contentCanScrollBackward, ), ) } ContentPaddingsHorizontalPagerWithTab( tabList = tabs, blurEnabled = progress >= 1F, ) } } } @Composable private fun InstanceModLine( uiState: InstanceDetailUiState, onUserClick: (PlatformLocator, WebFinger, String?) -> Unit, ) { val instance = uiState.instance val loading = uiState.loading Column { val languageString = instance?.languages?.joinToString(", ").orEmpty() Text( modifier = Modifier .padding(top = 4.dp) .freadPlaceholder(visible = loading), text = stringResource( LocalizedString.activity_pub_instance_detail_language_label, languageString ), maxLines = 3, style = MaterialTheme.typography.bodyMedium, ) Text( modifier = Modifier .padding(top = 4.dp) .freadPlaceholder(visible = loading), text = stringResource( LocalizedString.activity_pub_instance_detail_active_month_label, instance?.usage?.users?.activeMonth.toString() ), style = MaterialTheme.typography.bodyMedium, ) if (instance?.contact != null) { Row( modifier = Modifier .fillMaxWidth() .padding(top = 6.dp) .freadPlaceholder(visible = loading) .noRippleClick { val account = uiState.modAccount ?: return@noRippleClick val role = PlatformLocator(accountUri = account.uri, baseUrl = uiState.baseUrl) onUserClick(role, account.webFinger, account.userId) }, verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = Modifier .background( MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(4.dp), ) .padding(horizontal = 6.dp), text = "MOD", fontSize = 10.sp, color = MaterialTheme.colorScheme.onPrimary, ) AutoSizeImage( instance.contact?.account?.avatar.orEmpty(), modifier = Modifier .padding(start = 6.dp) .size(22.dp) .clip(CircleShape), contentDescription = "Mod avatar", ) FreadRichText( modifier = Modifier .padding(start = 4.dp), content = instance.contact?.account?.displayName.orEmpty(), emojis = uiState.modAccount?.emojis ?: emptyList(), onUrlClick = {}, ) } } } } @Composable private fun InstanceDetailActions(baseUrl: FormalBaseUrl) { val activityTextHandler = LocalTextHandler.current val browserLauncher = LocalActivityBrowserLauncher.current var showMorePopup by remember { mutableStateOf(false) } SimpleIconButton( onClick = { showMorePopup = true }, imageVector = Icons.Default.MoreVert, contentDescription = "More Options" ) PopupMenu( expanded = showMorePopup, onDismissRequest = { showMorePopup = false }, ) { DropDownOpenInBrowserItem { showMorePopup = false browserLauncher.launchBySystemBrowser(baseUrl.toString()) } DropDownCopyLinkItem { showMorePopup = false activityTextHandler.copyText(baseUrl.toString()) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/instance/InstanceDetailUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.instance import com.zhangke.activitypub.entities.ActivityPubInstanceEntity import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.status.author.BlogAuthor data class InstanceDetailUiState( val loading: Boolean, val baseUrl: FormalBaseUrl, val instance: ActivityPubInstanceEntity?, val modAccount: BlogAuthor?, ) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/instance/InstanceDetailViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.instance import androidx.lifecycle.ViewModel import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubAccountEntityAdapter import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow class InstanceDetailViewModel ( private val platformRepo: ActivityPubPlatformRepo, private val authorAdapter: ActivityPubAccountEntityAdapter, private val serverBaseUrl: FormalBaseUrl, ) : ViewModel() { private val _uiState = MutableStateFlow( InstanceDetailUiState( loading = false, baseUrl = serverBaseUrl, instance = null, modAccount = null, ) ) val uiState = _uiState.asStateFlow() init { _uiState.value = _uiState.value.copy( loading = true, baseUrl = serverBaseUrl ) launchInViewModel { val platform = platformRepo.getInstanceEntity(serverBaseUrl).getOrNull() _uiState.value = _uiState.value.copy( loading = false, instance = platform, modAccount = platform?.contact?.account?.let { authorAdapter.toAuthor(it) } ) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/instance/about/ServerAboutPage.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.instance.about import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.zhangke.activitypub.entities.ActivityPubAnnouncementEntity import com.zhangke.activitypub.entities.ActivityPubInstanceEntity import com.zhangke.framework.composable.LocalContentPadding import com.zhangke.framework.nav.BaseTab import com.zhangke.framework.nav.TabOptions import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.common.browser.launchWebTabInApp import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.ui.richtext.FreadRichText import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel class ServerAboutTab( val baseUrl: FormalBaseUrl, val rules: List = emptyList(), val contentCanScrollBackward: MutableState, ) : BaseTab() { override val options: TabOptions @Composable get() = TabOptions( title = stringResource(LocalizedString.activity_pub_about), ) @Composable override fun Content() { super.Content() val viewModel: ServerAboutViewModel = koinViewModel() LaunchedEffect(viewModel) { viewModel.rules = rules viewModel.baseUrl = baseUrl viewModel.onPageResume() } val uiState by viewModel.uiState.collectAsState() ServerAboutPageContent( uiState = uiState, contentCanScrollBackward = contentCanScrollBackward, baseUrl = baseUrl, ) } @Composable private fun ServerAboutPageContent( uiState: ServerAboutUiState, contentCanScrollBackward: MutableState, baseUrl: FormalBaseUrl, ) { val scrollState = rememberScrollState() contentCanScrollBackward.value = scrollState.value > 0 Column( modifier = Modifier .fillMaxSize() .padding(LocalContentPadding.current) .verticalScroll(scrollState), ) { if (uiState.announcement.isNotEmpty()) { ServerAboutAnnouncementSection( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp), announcementList = uiState.announcement, baseUrl = baseUrl, ) } if (uiState.rules.isNotEmpty()) { ServerAboutRulesSection( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 36.dp), ruleList = uiState.rules, ) } } } @Composable private fun ServerAboutAnnouncementSection( modifier: Modifier = Modifier, announcementList: List, baseUrl: FormalBaseUrl, ) { Column(modifier = modifier) { announcementList.forEach { ServerAboutAnnouncement( modifier = Modifier.fillMaxWidth(), entity = it, baseUrl = baseUrl, ) } } } @Composable private fun ServerAboutAnnouncement( modifier: Modifier = Modifier, entity: ActivityPubAnnouncementEntity, baseUrl: FormalBaseUrl, ) { val browserLauncher = LocalActivityBrowserLauncher.current val coroutineScope = rememberCoroutineScope() SelectionContainer { FreadRichText( modifier = modifier, content = entity.content, onUrlClick = { val locator = PlatformLocator(accountUri = null, baseUrl = baseUrl) browserLauncher.launchWebTabInApp(coroutineScope, it, locator) }, ) } } @Composable private fun ServerAboutRulesSection( modifier: Modifier = Modifier, ruleList: List, ) { SelectionContainer { Column(modifier = modifier) { Text( modifier = Modifier, text = stringResource(LocalizedString.activity_pub_about_rule_title), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge, ) Spacer(modifier = Modifier.height(4.dp)) HorizontalDivider() Spacer(modifier = Modifier.height(4.dp)) ruleList.forEachIndexed { index, rule -> ServerAboutRule( modifier = Modifier, rule = rule, index = index, ) Spacer(modifier = Modifier.height(4.dp)) if (index != ruleList.lastIndex) { HorizontalDivider() Spacer(modifier = Modifier.height(4.dp)) } } Spacer(modifier = Modifier.height(80.dp)) } } } @Composable private fun ServerAboutRule( modifier: Modifier = Modifier, rule: ActivityPubInstanceEntity.Rule, index: Int, ) { Row(modifier = modifier) { Text( text = "${index + 1}.", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = Color.Black, ) Text( modifier = Modifier.padding(start = 6.dp), text = rule.text, fontSize = 14.sp, ) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/instance/about/ServerAboutUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.instance.about import com.zhangke.activitypub.entities.ActivityPubAnnouncementEntity import com.zhangke.activitypub.entities.ActivityPubInstanceEntity import com.zhangke.fread.activitypub.app.internal.model.ActivityPubInstanceRule data class ServerAboutUiState( val announcement: List, val rules: List, ) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/instance/about/ServerAboutViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.instance.about import androidx.lifecycle.ViewModel import com.zhangke.activitypub.entities.ActivityPubInstanceEntity import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.repo.account.ActivityPubLoggedAccountRepo import com.zhangke.fread.activitypub.app.internal.usecase.GetInstanceAnnouncementUseCase import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update class ServerAboutViewModel ( private val accountRepo: ActivityPubLoggedAccountRepo, private val getInstanceAnnouncementUseCase: GetInstanceAnnouncementUseCase, ) : ViewModel() { lateinit var baseUrl: FormalBaseUrl var rules: List = emptyList() private val _uiState = MutableStateFlow(ServerAboutUiState(emptyList(), emptyList())) val uiState: StateFlow = _uiState fun onPageResume() { _uiState.update { it.copy(rules = rules) } requestAnnouncement() } private fun requestAnnouncement() { launchInViewModel { if (accountRepo.queryAll().isEmpty()) return@launchInViewModel getInstanceAnnouncementUseCase(baseUrl) .onSuccess { announcements -> _uiState.update { it.copy(announcement = announcements) } } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/instance/tags/ServerTrendsTagsPage.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.instance.tags import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.zhangke.framework.composable.LocalContentPadding import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.nav.BaseTab import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.TabOptions import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.screen.hashtag.HashtagTimelineScreenKey import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.Hashtag import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.ui.hashtag.HashtagUi import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel class ServerTrendsTagsTab( val baseUrl: FormalBaseUrl, val contentCanScrollBackward: MutableState, ) : BaseTab() { override val options: TabOptions @Composable get() = TabOptions( title = stringResource(LocalizedString.activity_pub_trends_tag), ) @Composable override fun Content() { super.Content() val backStack = LocalNavBackStack.currentOrThrow val viewModel = koinViewModel() viewModel.baseUrl = baseUrl val uiState by viewModel.uiState.collectAsState() LaunchedEffect(Unit) { viewModel.onPageResume() } ServerTrendsTagsContent( uiState = uiState, contentCanScrollBackward = contentCanScrollBackward, onHashtagClick = { tag -> val locator = PlatformLocator(accountUri = null, baseUrl = baseUrl) backStack.add( HashtagTimelineScreenKey( locator = locator, hashtag = tag.name.removePrefix("#"), ) ) }, ) } @Composable private fun ServerTrendsTagsContent( uiState: ServerTrendsTagsUiState, contentCanScrollBackward: MutableState, onHashtagClick: (Hashtag) -> Unit, ) { val listState = rememberLazyListState() val canScrollBackward by remember { derivedStateOf { listState.firstVisibleItemIndex != 0 || listState.firstVisibleItemScrollOffset != 0 } } contentCanScrollBackward.value = canScrollBackward LazyColumn( modifier = Modifier.fillMaxSize(), state = listState, contentPadding = LocalContentPadding.current, ) { items(uiState.list) { item -> HashtagUi( tag = item, onClick = onHashtagClick, ) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/instance/tags/ServerTrendsTagsUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.instance.tags import com.zhangke.fread.status.model.Hashtag data class ServerTrendsTagsUiState( val list: List ) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/instance/tags/ServerTrendsTagsViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.instance.tags import androidx.lifecycle.ViewModel import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.usecase.GetServerTrendTagsUseCase import com.zhangke.fread.status.model.PlatformLocator import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update class ServerTrendsTagsViewModel ( private val getServerTrendsTags: GetServerTrendTagsUseCase, ) : ViewModel() { lateinit var baseUrl: FormalBaseUrl private val _uiState = MutableStateFlow(ServerTrendsTagsUiState(emptyList())) val uiState: StateFlow = _uiState fun onPageResume() { launchInViewModel { getServerTrendsTags(PlatformLocator(accountUri = null, baseUrl = baseUrl)) .onSuccess { list -> _uiState.update { it.copy(list = list) } }.onFailure { } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/list/CreatedListsScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.list import androidx.compose.foundation.clickable 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.currentOrThrow import com.zhangke.activitypub.entities.ActivityPubListEntity import com.zhangke.framework.architect.json.globalJson import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.DefaultFailed import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.activitypub.app.internal.screen.list.add.AddListScreenNavKey import com.zhangke.fread.activitypub.app.internal.screen.list.edit.EditListScreenNavKey import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable data class CreatedListsScreenKey(val locator: PlatformLocator) : NavKey @Composable fun CreatedListsScreen( viewModel: CreatedListsViewModel, locator: PlatformLocator, ) { val backStack = LocalNavBackStack.currentOrThrow val uiState by viewModel.uiState.collectAsState() val snackBarState = rememberSnackbarHostState() CreatedListsContent( uiState = uiState, snackBarState = snackBarState, onBackClick = backStack::removeLastOrNull, onRetryClick = viewModel::onRetryClick, onListClick = { backStack.add( EditListScreenNavKey( locator = locator, serializedList = globalJson.encodeToString(it) ) ) }, onAddListClick = { backStack.add(AddListScreenNavKey(locator)) }, ) LaunchedEffect(Unit) { viewModel.onPageResume() } ConsumeSnackbarFlow(snackBarState, viewModel.snackBarFlow) } @Composable private fun CreatedListsContent( uiState: CreatedListsUiState, snackBarState: SnackbarHostState, onBackClick: () -> Unit, onAddListClick: () -> Unit, onListClick: (ActivityPubListEntity) -> Unit, onRetryClick: () -> Unit, ) { Scaffold( topBar = { Toolbar( title = stringResource(LocalizedString.activity_pub_created_list_title), onBackClick = onBackClick, ) }, snackbarHost = { SnackbarHost(snackBarState) }, floatingActionButton = { FloatingActionButton( containerColor = MaterialTheme.colorScheme.surface, onClick = onAddListClick, ) { Icon( painter = rememberVectorPainter(image = Icons.Default.Add), contentDescription = "Create List", ) } }, ) { innerPadding -> Box( modifier = Modifier.fillMaxSize().padding(innerPadding), ) { when { uiState.lists.isNotEmpty() -> { LazyColumn( modifier = Modifier.fillMaxSize(), ) { items(uiState.lists) { ListItem( modifier = Modifier.fillMaxWidth() .clickable { onListClick(it) }, list = it, ) } } } uiState.loading -> { Column( modifier = Modifier.fillMaxSize(), ) { repeat(30) { ListItemPlaceholder() } } } uiState.pageError != null -> { DefaultFailed( modifier = Modifier.fillMaxSize(), exception = uiState.pageError, onRetryClick = onRetryClick, ) } } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/list/CreatedListsUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.list import com.zhangke.activitypub.entities.ActivityPubListEntity data class CreatedListsUiState( val lists: List, val loading: Boolean, val pageError: Throwable?, ) { companion object { fun default( loading: Boolean = false, ): CreatedListsUiState { return CreatedListsUiState( loading = loading, pageError = null, lists = emptyList(), ) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/list/CreatedListsViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.list import androidx.lifecycle.ViewModel import com.zhangke.framework.composable.TextString import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.activitypub.app.internal.usecase.content.GetUserCreatedListUseCase import com.zhangke.fread.status.model.PlatformLocator import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update class CreatedListsViewModel ( private val getUserCreatedList: GetUserCreatedListUseCase, private val locator: PlatformLocator, ) : ViewModel() { private val _uiState = MutableStateFlow(CreatedListsUiState.default()) val uiState = _uiState.asStateFlow() private val _snackBarFlow = MutableSharedFlow() val snackBarFlow = _snackBarFlow.asSharedFlow() private var getListJob: Job? = null init { getUserLists() } fun onRetryClick() { getUserLists() } fun onPageResume() { getUserLists() } private fun getUserLists() { if (getListJob?.isActive == true) return _uiState.update { it.copy(loading = true) } getListJob = launchInViewModel { getUserCreatedList(locator) .onSuccess { list -> _uiState.update { it.copy(loading = false, lists = list) } }.onFailure { t -> _uiState.update { it.copy(loading = false, pageError = t) } } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/list/ListDetailPageContent.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.list import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Save import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Switch import androidx.compose.material3.Text 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.draw.clip import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.framework.composable.DefaultFailed import com.zhangke.framework.composable.FreadDialog import com.zhangke.framework.composable.PopupMenu import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.freadPlaceholder import com.zhangke.framework.composable.noRippleClick import com.zhangke.fread.activitypub.app.internal.screen.list.edit.ListRepliesPolicy import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.ui.BlogAuthorAvatar import org.jetbrains.compose.resources.stringResource @Composable internal fun ListDetailPageContent( name: TextFieldValue, repliesPolicy: ListRepliesPolicy, exclusive: Boolean, showLoadingCover: Boolean, accountList: List, snackBarState: SnackbarHostState, accountsLoading: Boolean, loadAccountsError: Throwable?, showDeleteIcon: Boolean, onBackClick: () -> Unit, onSaveClick: () -> Unit, onAddUserClick: () -> Unit, onExclusiveChangeRequest: (Boolean) -> Unit, onRemoveAccount: (ActivityPubAccountEntity) -> Unit, onRetryLoadAccountsClick: () -> Unit, onPolicySelect: (ListRepliesPolicy) -> Unit, onNameChangedRequest: (TextFieldValue) -> Unit, onDeleteClick: () -> Unit = {}, ) { Scaffold( topBar = { Toolbar( title = stringResource(LocalizedString.activity_pub_add_list_title), onBackClick = onBackClick, actions = { if (showDeleteIcon) { var showDeleteDialog by remember { mutableStateOf(false) } if (showDeleteDialog) { FreadDialog( onDismissRequest = { showDeleteDialog = false }, contentText = stringResource(LocalizedString.activity_pub_list_delete_confirm), onNegativeClick = { showDeleteDialog = false }, onPositiveClick = { onDeleteClick() showDeleteDialog = false }, ) } Toolbar.DeleteButton( onDeleteClick = { showDeleteDialog = true }, ) } IconButton( onClick = onSaveClick, ) { Icon( imageVector = Icons.Default.Save, contentDescription = "Save", ) } } ) }, floatingActionButton = { FloatingActionButton( containerColor = MaterialTheme.colorScheme.surface, onClick = onAddUserClick, ) { Icon( painter = rememberVectorPainter(image = Icons.Default.Add), contentDescription = "Add User", ) } }, snackbarHost = { SnackbarHost(snackBarState) }, ) { innerPadding -> Box( modifier = Modifier.fillMaxSize() .padding(innerPadding), ) { LazyColumn( modifier = Modifier.fillMaxSize(), ) { item { ListDetailSetting( name = name, repliesPolicy = repliesPolicy, exclusive = exclusive, onExclusiveChangeRequest = onExclusiveChangeRequest, onNameChangedRequest = onNameChangedRequest, onPolicySelect = onPolicySelect, ) } if (accountList.isNotEmpty()) { items(accountList) { AccountItem( account = it, showRemoveIcon = true, onRemoveAccount = onRemoveAccount, ) } } else if (accountsLoading) { items(20) { AccountPlaceholder() } } else if (loadAccountsError != null) { item { Box( modifier = Modifier.fillMaxWidth() .height(300.dp) ) { DefaultFailed( modifier = Modifier.fillMaxSize(), exception = loadAccountsError, onRetryClick = onRetryLoadAccountsClick, ) } } } } if (showLoadingCover) { Box( modifier = Modifier.fillMaxSize() .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6F)) .noRippleClick { } ) { CircularProgressIndicator( modifier = Modifier.align(Alignment.Center).size(64.dp) ) } } } } } @Composable private fun ListDetailSetting( name: TextFieldValue, repliesPolicy: ListRepliesPolicy, exclusive: Boolean, onExclusiveChangeRequest: (Boolean) -> Unit, onPolicySelect: (ListRepliesPolicy) -> Unit, onNameChangedRequest: (TextFieldValue) -> Unit, ) { Column(modifier = Modifier.fillMaxSize()) { OutlinedTextField( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, top = 16.dp, end = 16.dp), value = name, onValueChange = { onNameChangedRequest(it) }, label = { Text( text = stringResource(LocalizedString.activity_pub_add_list_name) ) }, ) var showPolicySelector by remember { mutableStateOf(false) } Box( modifier = Modifier.fillMaxWidth() .pointerInput(Unit) { awaitEachGesture { while (true) { val event = awaitPointerEvent() event.changes.forEach { it.consume() } if (event.type == PointerEventType.Release) { showPolicySelector = true } } } }, ) { OutlinedTextField( modifier = Modifier .focusable(false) .fillMaxWidth() .padding(start = 16.dp, top = 16.dp, end = 16.dp), value = repliesPolicy.showName, readOnly = true, onValueChange = { }, label = { Text(text = stringResource(LocalizedString.activity_pub_add_list_replies)) }, ) PopupMenu( modifier = Modifier.align(Alignment.BottomStart), expanded = showPolicySelector, offset = DpOffset(16.dp, 0.dp), onDismissRequest = { showPolicySelector = false }, ) { ListRepliesPolicy.entries.forEach { policy -> DropdownMenuItem( text = { Text(text = policy.showName) }, onClick = { showPolicySelector = false onPolicySelect(policy) }, ) } } } Row( modifier = Modifier.fillMaxWidth() .padding(start = 16.dp, top = 16.dp, end = 8.dp) ) { Column(modifier = Modifier.weight(1F)) { Text( text = stringResource(LocalizedString.activity_pub_add_list_hide_in_timeline), style = MaterialTheme.typography.titleMedium, ) Text( modifier = Modifier.padding(top = 1.dp), text = stringResource(LocalizedString.activity_pub_add_list_hide_in_timeline_desc), style = MaterialTheme.typography.bodyMedium, ) } Switch( modifier = Modifier.padding(start = 8.dp), checked = exclusive, onCheckedChange = { onExclusiveChangeRequest(it) }, ) } Text( modifier = Modifier.padding(start = 16.dp, top = 16.dp), text = stringResource(LocalizedString.activity_pub_add_list_replies_list), style = MaterialTheme.typography.titleMedium, ) } } @Composable internal fun AccountItem( account: ActivityPubAccountEntity, showRemoveIcon: Boolean, modifier: Modifier = Modifier, onRemoveAccount: (ActivityPubAccountEntity) -> Unit = {}, ) { Row( modifier = modifier.fillMaxWidth() .padding(start = 16.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { BlogAuthorAvatar( modifier = Modifier.size(42.dp), imageUrl = account.avatar, ) Column( modifier = Modifier.weight(1F).padding(start = 8.dp), ) { Text( text = account.displayName, style = MaterialTheme.typography.titleMedium, ) Text( text = account.acct, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } if (showRemoveIcon) { var showRemoveConfirmDialog by remember { mutableStateOf(false) } IconButton( modifier = Modifier.padding(start = 8.dp), onClick = { showRemoveConfirmDialog = true }, ) { Icon( imageVector = Icons.Default.Delete, contentDescription = "Remove", ) } if (showRemoveConfirmDialog) { FreadDialog( onDismissRequest = { showRemoveConfirmDialog = false }, contentText = stringResource(LocalizedString.activity_pub_add_list_remove_user_message), onNegativeClick = { showRemoveConfirmDialog = false }, onPositiveClick = { showRemoveConfirmDialog = false onRemoveAccount(account) }, ) } } } } @Composable private fun AccountPlaceholder() { Row( modifier = Modifier.fillMaxWidth() .padding(start = 16.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Box(modifier = Modifier.size(42.dp).clip(CircleShape).freadPlaceholder(true)) Column( modifier = Modifier.weight(1F).padding(start = 8.dp), ) { Box(modifier = Modifier.height(16.dp).width(100.dp).freadPlaceholder(true)) Box( modifier = Modifier.padding(top = 2.dp).height(14.dp).width(100.dp) .freadPlaceholder(true) ) } } } private val ListRepliesPolicy.showName: String @Composable get() { return when (this) { ListRepliesPolicy.FOLLOWING -> stringResource(LocalizedString.activity_pub_add_list_replies_followers) ListRepliesPolicy.LIST -> stringResource(LocalizedString.activity_pub_add_list_replies_list) ListRepliesPolicy.NONE -> stringResource(LocalizedString.activity_pub_add_list_replies_non) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/list/ListItem.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.list import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ListAlt import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.zhangke.activitypub.entities.ActivityPubListEntity import com.zhangke.framework.composable.freadPlaceholder @Composable internal fun ListItem( modifier: Modifier, list: ActivityPubListEntity, ) { Row( modifier = modifier.padding(vertical = 16.dp, horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = Icons.AutoMirrored.Filled.ListAlt, contentDescription = null, ) Text( modifier = Modifier.padding(start = 8.dp), text = list.title, ) } } @Composable internal fun ListItemPlaceholder() { Row( modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier .size(24.dp) .freadPlaceholder(visible = true), ) Box( modifier = Modifier .padding(start = 8.dp) .width(100.dp) .height(16.dp) .freadPlaceholder(visible = true), ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/list/add/AddListScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.list.add import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.activitypub.app.internal.screen.list.ListDetailPageContent import com.zhangke.fread.activitypub.app.internal.screen.user.search.SearchUserScreenNavKey import com.zhangke.fread.status.model.PlatformLocator import kotlinx.serialization.Serializable @Serializable data class AddListScreenNavKey(val locator: PlatformLocator) : NavKey @Composable fun AddListScreen(viewModel: AddListViewModel, locator: PlatformLocator) { val backStack = LocalNavBackStack.currentOrThrow val snackbarHostState = rememberSnackbarHostState() val uiState by viewModel.uiState.collectAsState() ConsumeFlow(SearchUserScreenNavKey.accountSelectedFlow.flow) { viewModel.onAddAccount(it) } ListDetailPageContent( name = uiState.name, snackBarState = snackbarHostState, exclusive = uiState.exclusive, repliesPolicy = uiState.repliesPolicy, showLoadingCover = uiState.showLoadingCover, accountList = uiState.accountList, accountsLoading = false, loadAccountsError = null, showDeleteIcon = false, onSaveClick = viewModel::onSaveClick, onExclusiveChangeRequest = viewModel::onExclusiveChanged, onPolicySelect = viewModel::onPolicySelect, onBackClick = { backStack.removeLastOrNull() }, onRemoveAccount = viewModel::onRemoveAccount, onRetryLoadAccountsClick = {}, onNameChangedRequest = viewModel::onNameChangeRequest, onAddUserClick = { backStack.add(SearchUserScreenNavKey(locator, onlyFollowing = false)) }, ) ConsumeSnackbarFlow(snackbarHostState, viewModel.snackBarFlow) ConsumeFlow(viewModel.finishPageFlow) { backStack.removeLastOrNull() } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/list/add/AddListUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.list.add import androidx.compose.ui.text.input.TextFieldValue import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.fread.activitypub.app.internal.screen.list.edit.ListRepliesPolicy data class AddListUiState( val name: TextFieldValue, val repliesPolicy: ListRepliesPolicy, val exclusive: Boolean, val showLoadingCover: Boolean, val accountList: List, val contentHasChanged: Boolean, ) { companion object { fun default(): AddListUiState { return AddListUiState( name = TextFieldValue(""), repliesPolicy = ListRepliesPolicy.LIST, exclusive = false, showLoadingCover = false, accountList = emptyList(), contentHasChanged = false, ) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/list/add/AddListViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.list.add import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.emitInViewModel import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.composable.textOf import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.screen.list.edit.ListRepliesPolicy import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update class AddListViewModel ( private val clientManager: ActivityPubClientManager, private val locator: PlatformLocator, ) : ViewModel() { private val _uiState = MutableStateFlow(AddListUiState.default()) val uiState = _uiState.asStateFlow() private val _snackBarFlow = MutableSharedFlow() val snackBarFlow = _snackBarFlow.asSharedFlow() private val _finishPageFlow = MutableSharedFlow() val finishPageFlow = _finishPageFlow.asSharedFlow() fun onPolicySelect(policy: ListRepliesPolicy) { _uiState.update { it.copy(repliesPolicy = policy) } checkContentHasChanged() } fun onNameChangeRequest(name: TextFieldValue) { _uiState.update { state -> state.copy(name = name) } checkContentHasChanged() } fun onExclusiveChanged(exclusive: Boolean) { _uiState.update { state -> state.copy(exclusive = exclusive) } checkContentHasChanged() } fun onAddAccount(entity: ActivityPubAccountEntity) { if (_uiState.value.accountList.any { it.id == entity.id }) return _uiState.update { state -> state.copy(accountList = state.accountList + entity) } checkContentHasChanged() } fun onRemoveAccount(entity: ActivityPubAccountEntity) { _uiState.update { state -> state.copy(accountList = state.accountList - entity) } checkContentHasChanged() } fun onSaveClick() { val currentUiState = _uiState.value if (!currentUiState.contentHasChanged) return if (currentUiState.name.text.isEmpty()) { _snackBarFlow.emitInViewModel(textOf(LocalizedString.activity_pub_add_list_name_is_empty)) return } launchInViewModel { _uiState.update { it.copy(showLoadingCover = true) } val listsRepo = clientManager.getClient(locator).listsRepo listsRepo.createList( title = currentUiState.name.text, repliesPolicy = currentUiState.repliesPolicy.apiName, exclusive = currentUiState.exclusive, ).onSuccess { entity -> if (currentUiState.accountList.isNotEmpty()) { listsRepo.postAccountInList( listId = entity.id, accountIds = currentUiState.accountList.map { it.id }, ).onSuccess { _uiState.update { it.copy(showLoadingCover = false) } _finishPageFlow.emit(Unit) }.onFailure { _uiState.update { it.copy(showLoadingCover = false) } _snackBarFlow.emitTextMessageFromThrowable(it) } } else { _uiState.update { it.copy(showLoadingCover = false) } _finishPageFlow.emit(Unit) } }.onFailure { _uiState.update { it.copy(showLoadingCover = false) } _snackBarFlow.emitTextMessageFromThrowable(it) } } } private fun checkContentHasChanged() { var hasChanged = false val currentUiState = _uiState.value if (currentUiState.name.text.isNotEmpty()) { hasChanged = true } if (currentUiState.repliesPolicy != ListRepliesPolicy.LIST) { hasChanged = true } if (currentUiState.exclusive) { hasChanged = true } if (currentUiState.accountList.isNotEmpty()) { hasChanged = true } if (hasChanged != currentUiState.contentHasChanged) { _uiState.update { it.copy(contentHasChanged = hasChanged) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/list/edit/EditListScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.list.edit import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import com.zhangke.framework.composable.BackHandler import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.FreadDialog import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.activitypub.app.internal.screen.list.ListDetailPageContent import com.zhangke.fread.activitypub.app.internal.screen.user.search.SearchUserScreenNavKey import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable data class EditListScreenNavKey( val locator: PlatformLocator, val serializedList: String, ) : NavKey @OptIn(ExperimentalComposeUiApi::class) @Composable fun EditListScreen( locator: PlatformLocator, viewModel: EditListViewModel, ) { val backStack = LocalNavBackStack.currentOrThrow val uiState by viewModel.uiState.collectAsState() val snackBarState = rememberSnackbarHostState() var showBackReminder by remember { mutableStateOf(false) } fun onBack() { if (uiState.contentHasChanged) { showBackReminder = true } else { backStack.removeLastOrNull() } } BackHandler(true) { onBack() } if (showBackReminder) { FreadDialog( onDismissRequest = { showBackReminder = false }, contentText = stringResource(LocalizedString.activity_pub_add_list_back_reminder), onNegativeClick = { showBackReminder = false }, onPositiveClick = { showBackReminder = false backStack.removeLastOrNull() }, ) } ConsumeFlow(SearchUserScreenNavKey.accountSelectedFlow.flow) { viewModel.onAddUser(it) } ListDetailPageContent( name = uiState.name, repliesPolicy = uiState.repliesPolicy, exclusive = uiState.exclusive, showLoadingCover = uiState.showLoadingCover, accountList = uiState.accountList, snackBarState = snackBarState, showDeleteIcon = true, accountsLoading = uiState.accountsLoading, loadAccountsError = uiState.loadAccountsError, onNameChangedRequest = viewModel::onNameChangeRequest, onExclusiveChangeRequest = viewModel::onExclusiveChanged, onRemoveAccount = viewModel::onRemoveAccount, onAddUserClick = { val searchUserScreen = SearchUserScreenNavKey(locator, true) backStack.add(searchUserScreen) }, onSaveClick = viewModel::onSaveClick, onBackClick = { onBack() }, onRetryLoadAccountsClick = viewModel::onRetryLoadAccountsClick, onPolicySelect = viewModel::onPolicySelect, onDeleteClick = viewModel::onDeleteClick, ) ConsumeSnackbarFlow(snackBarState, viewModel.snackBarFlow) ConsumeFlow(viewModel.finishPageFlow) { backStack.removeLastOrNull() } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/list/edit/EditListUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.list.edit import androidx.compose.ui.text.input.TextFieldValue import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.activitypub.entities.ActivityPubListEntity data class EditListUiState( val accountsLoading: Boolean, val loadAccountsError: Throwable?, val name: TextFieldValue, val repliesPolicy: ListRepliesPolicy, val exclusive: Boolean, val showLoadingCover: Boolean, val accountList: List, val contentHasChanged: Boolean, ) { companion object { fun default(list: ActivityPubListEntity): EditListUiState { return EditListUiState( accountsLoading = false, loadAccountsError = null, name = TextFieldValue(list.title), repliesPolicy = ListRepliesPolicy.fromName(list.repliesPolicy), exclusive = list.exclusive, showLoadingCover = false, accountList = emptyList(), contentHasChanged = false, ) } } } enum class ListRepliesPolicy(val apiName: String) { FOLLOWING("followed"), LIST("list"), NONE("none"); companion object { fun fromName(name: String): ListRepliesPolicy { return entries.first { it.apiName == name } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/list/edit/EditListViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.list.edit import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.activitypub.entities.ActivityPubListEntity import com.zhangke.framework.architect.json.globalJson import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.emitInViewModel import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.composable.textOf import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.utils.exceptionOrThrow import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope class EditListViewModel ( private val clientManager: ActivityPubClientManager, private val locator: PlatformLocator, private val serializedList: String, ) : ViewModel() { private val entity: ActivityPubListEntity = globalJson.decodeFromString(serializedList) private val _uiState = MutableStateFlow(EditListUiState.default(entity)) val uiState = _uiState.asStateFlow() private val _snackBarFlow = MutableSharedFlow() val snackBarFlow = _snackBarFlow.asSharedFlow() private val _finishPageFlow = MutableSharedFlow() val finishPageFlow = _finishPageFlow.asSharedFlow() private var originalAccountList: List = emptyList() init { launchInViewModel { getListDetail() } } private suspend fun getListDetail() { getAccountList() } fun onRetryLoadAccountsClick() { launchInViewModel { getAccountList() } } fun onPolicySelect(policy: ListRepliesPolicy) { _uiState.update { it.copy(repliesPolicy = policy) } checkContentHasChanged() } fun onDeleteClick() { launchInViewModel { clientManager.getClient(locator) .listsRepo .deleteList(entity.id) .onSuccess { _finishPageFlow.emit(Unit) }.onFailure { t -> _snackBarFlow.emitTextMessageFromThrowable(t) } } } fun onNameChangeRequest(name: TextFieldValue) { _uiState.update { state -> state.copy(name = name) } checkContentHasChanged() } fun onExclusiveChanged(exclusive: Boolean) { _uiState.update { state -> state.copy(exclusive = exclusive) } checkContentHasChanged() } fun onRemoveAccount(accountEntity: ActivityPubAccountEntity) { if (!originalAccountList.any { it.id == accountEntity.id }) { _uiState.update { it.copy(accountList = it.accountList - accountEntity) } return } _uiState.update { it.copy(showLoadingCover = true) } launchInViewModel { clientManager.getClient(locator) .listsRepo .deleteAccountsInList( listId = entity.id, accounts = listOf(accountEntity.id), ).onSuccess { _uiState.update { state -> state.copy( showLoadingCover = false, accountList = state.accountList - accountEntity, ) } originalAccountList = originalAccountList - accountEntity }.onFailure { t -> _uiState.update { state -> state.copy(showLoadingCover = false) } _snackBarFlow.emitTextMessageFromThrowable(t) } } } fun onSaveClick() { if (_uiState.value.name.text.isEmpty()) { _snackBarFlow.emitInViewModel(textOf(LocalizedString.activity_pub_add_list_name_is_empty)) return } _uiState.update { it.copy(showLoadingCover = true) } viewModelScope.launch { supervisorScope { val settingDeferred = async { updateSettingPart() } val accountDeferred = async { updateAccountList() } val settingResult = settingDeferred.await() val accountResult = accountDeferred.await() _uiState.update { it.copy(showLoadingCover = false) } if (settingResult.isFailure) { _snackBarFlow.emitTextMessageFromThrowable(settingResult.exceptionOrThrow()) } else if (accountResult.isFailure) { _snackBarFlow.emitTextMessageFromThrowable(accountResult.exceptionOrThrow()) } else { _finishPageFlow.emit(Unit) } } } } fun onAddUser(user: ActivityPubAccountEntity) { if (_uiState.value.accountList.any { it.id == entity.id }) return _uiState.update { it.copy(accountList = it.accountList + user) } } private suspend fun updateSettingPart(): Result { if (!checkSettingHasChanged()) return Result.success(Unit) return clientManager.getClient(locator).listsRepo .updateList( listId = entity.id, title = uiState.value.name.text, repliesPolicy = uiState.value.repliesPolicy.apiName, exclusive = uiState.value.exclusive, ).map { } } private suspend fun updateAccountList(): Result { if (!checkAccountHasChanged()) return Result.success(Unit) val ids = originalAccountList.map { it.id }.toSet() val newAccounts = _uiState.value.accountList.filter { !ids.contains(it.id) } return clientManager.getClient(locator).listsRepo .postAccountInList( listId = entity.id, accountIds = newAccounts.map { it.id }, ).map { } } private fun checkContentHasChanged() { val hasChanged = checkSettingHasChanged() || checkAccountHasChanged() if (_uiState.value.contentHasChanged != hasChanged) { _uiState.update { it.copy(contentHasChanged = hasChanged) } } } private fun checkSettingHasChanged(): Boolean { if (entity.title != uiState.value.name.text) { return true } if (entity.repliesPolicy != uiState.value.repliesPolicy.apiName) { return true } if (entity.exclusive != uiState.value.exclusive) { return true } return false } private fun checkAccountHasChanged(): Boolean { if (originalAccountList.size != _uiState.value.accountList.size) { return true } val ids = originalAccountList.map { it.id }.toSet() for (entity in _uiState.value.accountList) { if (!ids.contains(entity.id)) return true } return false } private suspend fun getAccountList() { _uiState.update { it.copy(accountsLoading = true) } clientManager.getClient(locator) .listsRepo .getAccountsInList(entity.id) .onSuccess { list -> originalAccountList = list _uiState.update { state -> state.copy( accountsLoading = false, accountList = list, ) } }.onFailure { t -> _uiState.update { state -> state.copy( accountsLoading = false, loadAccountsError = t, ) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/search/SearchStatusScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.search import androidx.compose.runtime.Composable import androidx.navigation3.runtime.NavKey import com.zhangke.fread.commonbiz.shared.screen.search.AbstractSearchStatusScreen import com.zhangke.fread.status.model.PlatformLocator import kotlinx.serialization.Serializable @Serializable data class SearchStatusScreenNavKey( val locator: PlatformLocator, val userId: String, ) : NavKey @Composable fun SearchStatusScreen(viewModel: SearchStatusViewModel) { AbstractSearchStatusScreen(viewModel) } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/search/SearchStatusViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.search import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.screen.search.AbstractSearchStatusViewModel import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.platform.BlogPlatform class SearchStatusViewModel ( private val clientManager: ActivityPubClientManager, statusProvider: StatusProvider, private val platformRepo: ActivityPubPlatformRepo, loggedAccountProvider: LoggedAccountProvider, statusUiStateAdapter: StatusUiStateAdapter, private val statusAdapter: ActivityPubStatusAdapter, refactorToNewStatus: RefactorToNewStatusUseCase, statusUpdater: StatusUpdater, private val locator: PlatformLocator, private val userId: String, ) : AbstractSearchStatusViewModel( statusProvider = statusProvider, statusUiStateAdapter = statusUiStateAdapter, statusUpdater = statusUpdater, refactorToNewStatus = refactorToNewStatus, ) { private var platform: BlogPlatform? = null private var loggedAccount: ActivityPubLoggedAccount? = loggedAccountProvider.getAccount(locator) private var maxId: String? = null init { launchInViewModel { platform = loadPlatform().getOrNull() } } override suspend fun performSearch( query: String, loadMore: Boolean ): Result> { if (!loadMore) { this.maxId = null } if (loadMore && maxId == null) { return Result.success(emptyList()) } val platformResult = loadPlatform() if (platformResult.isFailure) { return Result.failure(platformResult.exceptionOrNull()!!) } val platform = platformResult.getOrThrow() return clientManager.getClient(locator) .searchRepo .queryStatus( query = query, accountId = userId, maxId = maxId, limit = 20, ).map { statuses -> statuses.map { statusAdapter.toStatusUiState( entity = it, platform = platform, locator = locator, loggedAccount = loggedAccount, ) } }.onSuccess { this.maxId = it.lastOrNull()?.status?.id } } private suspend fun loadPlatform(): Result { if (platform != null) return Result.success(platform!!) return platformRepo.getPlatform(locator) .onSuccess { platform = it } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/status/post/PostStatusScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.status.post import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import com.zhangke.framework.composable.BackHandler import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.FreadDialog import com.zhangke.framework.composable.LoadableLayout import com.zhangke.framework.composable.LoadableState import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.composable.requireSuccessData import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.popIfNotRoot import com.zhangke.framework.nav.replaceTopOrAdd import com.zhangke.framework.toast.toast import com.zhangke.framework.utils.Locale import com.zhangke.framework.utils.PlatformUri import com.zhangke.framework.utils.TextFieldUtils import com.zhangke.fread.activitypub.app.internal.screen.status.post.composable.PostStatusBottomBar import com.zhangke.fread.activitypub.app.internal.screen.status.post.composable.PostStatusPoll import com.zhangke.fread.activitypub.app.internal.utils.DeleteTextUtil import com.zhangke.fread.common.utils.MentionTextUtil import com.zhangke.fread.commonbiz.shared.screen.publish.PublishPostMedia import com.zhangke.fread.commonbiz.shared.screen.publish.PublishPostMediaAttachment import com.zhangke.fread.commonbiz.shared.screen.publish.PublishPostScaffold import com.zhangke.fread.commonbiz.shared.screen.publish.composable.PostStatusVisibilityUi import com.zhangke.fread.commonbiz.shared.screen.publish.composable.PostStatusWarning import com.zhangke.fread.commonbiz.shared.screen.publish.multi.MultiAccountPublishingScreenKey import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.QuoteApprovalPolicy import com.zhangke.fread.status.model.StatusVisibility import com.zhangke.fread.status.ui.common.SelectAccountDialog import com.zhangke.fread.status.ui.embed.UnavailableQuoteInEmbedding import com.zhangke.fread.status.ui.embed.embedBorder import com.zhangke.fread.status.ui.publish.BlogInQuoting import com.zhangke.fread.status.ui.publish.PublishBlogStyle import com.zhangke.fread.status.uri.FormalUri import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource import kotlin.time.Duration @Serializable data class PostStatusScreenKey( val accountUri: FormalUri, val defaultContent: String? = null, val editBlogJsonString: String? = null, val replyingBlogJsonString: String? = null, val quoteBlogJsonString: String? = null, ) : NavKey @OptIn(ExperimentalComposeUiApi::class) @Composable fun PostStatusScreen(viewModel: PostStatusViewModel) { val backStack = LocalNavBackStack.currentOrThrow val loadableUiState by viewModel.uiState.collectAsState() var showExitDialog by remember { mutableStateOf(false) } val snackMessageState = rememberSnackbarHostState() fun onBack() { if (loadableUiState !is LoadableState.Success) { backStack.popIfNotRoot() return } if (loadableUiState.requireSuccessData().hasInputtedData()) { showExitDialog = true return } backStack.popIfNotRoot() } LoadableLayout( modifier = Modifier.fillMaxSize(), state = loadableUiState, ) { uiState -> PostStatusScreenContent( uiState = uiState, snackMessageState = snackMessageState, onSwitchAccount = viewModel::onSwitchAccountClick, onContentChanged = viewModel::onContentChanged, onCloseClick = { onBack() }, onPostClick = viewModel::onPostClick, onSensitiveClick = viewModel::onSensitiveClick, onMediaSelected = viewModel::onMediaSelected, onQuoteApprovalPolicySelect = viewModel::onQuoteApprovalPolicySelect, onLanguageSelected = viewModel::onLanguageSelected, onDeleteClick = viewModel::onMediaDeleteClick, onDescriptionInputted = viewModel::onDescriptionInputted, onPollClicked = viewModel::onPollClicked, onPollContentChanged = viewModel::onPollContentChanged, onAddPollItemClick = viewModel::onAddPollItemClick, onRemovePollClick = viewModel::onRemovePollClick, onRemovePollItemClick = viewModel::onRemovePollItemClick, onPollStyleSelect = viewModel::onPollStyleSelect, onWarningContentChanged = viewModel::onWarningContentChanged, onVisibilityChanged = viewModel::onVisibilityChanged, onDurationSelect = viewModel::onDurationSelect, onAddAccountClick = { val multiAccPublishScreen = MultiAccountPublishingScreenKey.create(listOf(uiState.account)) if (uiState.hasInputtedData()) { backStack.add(multiAccPublishScreen) } else { backStack.replaceTopOrAdd(multiAccPublishScreen) } }, ) } val successMessage = stringResource(LocalizedString.postStatusSuccess) ConsumeFlow(viewModel.publishSuccessFlow) { toast(successMessage) backStack.popIfNotRoot() } BackHandler(true) { onBack() } if (showExitDialog) { FreadDialog( onDismissRequest = { showExitDialog = false }, content = { Text(text = stringResource(LocalizedString.postStatusExitDialogContent)) }, onNegativeClick = { showExitDialog = false }, onPositiveClick = { showExitDialog = false backStack.popIfNotRoot() }, ) } ConsumeSnackbarFlow(snackMessageState, viewModel.snackMessage) } @Composable private fun PostStatusScreenContent( uiState: PostStatusUiState, snackMessageState: SnackbarHostState, onSwitchAccount: (LoggedAccount) -> Unit, onContentChanged: (TextFieldValue) -> Unit, onCloseClick: () -> Unit, onPostClick: () -> Unit, onSensitiveClick: () -> Unit, onMediaSelected: (List) -> Unit, onDeleteClick: (PublishPostMedia) -> Unit, onQuoteApprovalPolicySelect: (QuoteApprovalPolicy) -> Unit, onDescriptionInputted: (PublishPostMedia, String) -> Unit, onLanguageSelected: (Locale) -> Unit, onPollClicked: () -> Unit, onPollContentChanged: (Int, String) -> Unit, onRemovePollClick: () -> Unit, onRemovePollItemClick: (Int) -> Unit, onAddPollItemClick: () -> Unit, onPollStyleSelect: (multiple: Boolean) -> Unit, onWarningContentChanged: (TextFieldValue) -> Unit, onVisibilityChanged: (StatusVisibility) -> Unit, onDurationSelect: (Duration) -> Unit, onAddAccountClick: () -> Unit, ) { var showAccountSwitchPopup by remember { mutableStateOf(false) } PublishPostScaffold( account = uiState.account, snackBarHostState = snackMessageState, content = uiState.content, publishEnabled = uiState.publishEnabled, showSwitchAccountIcon = uiState.accountChangeable && uiState.availableAccountList.size > 1, showAddAccountIcon = uiState.showAddAccountIcon, publishing = uiState.publishing, replyingBlog = uiState.replyToBlog, onContentChanged = onContentChanged, onPublishClick = onPostClick, onBackClick = onCloseClick, onSwitchAccountClick = { showAccountSwitchPopup = true }, onAddAccountClick = onAddAccountClick, contentWarning = { if (uiState.sensitive) { PostStatusWarning( modifier = Modifier.fillMaxWidth() .padding(start = 16.dp, top = 16.dp, end = 16.dp), warning = uiState.warningContent, onValueChanged = onWarningContentChanged, ) } }, postSettingLabel = { if (uiState.rules.supportsQuotePost) { PublishInteractionSettingLabel( modifier = Modifier, visibility = uiState.visibility, quoteApprovalPolicy = uiState.quoteApprovalPolicy, visibilityChangeable = uiState.visibilityChangeable, quoteApprovalPolicyChangeable = uiState.quoteApprovalPolicyChangeable, onVisibilitySelect = onVisibilityChanged, onQuoteApprovalPolicySelect = onQuoteApprovalPolicySelect, ) } else { PostStatusVisibilityUi( modifier = Modifier, visibility = uiState.visibility, changeable = uiState.visibilityChangeable, onVisibilitySelect = onVisibilityChanged, ) } }, bottomPanel = { PostStatusBottomBar( uiState = uiState, onSensitiveClick = onSensitiveClick, onMediaSelected = onMediaSelected, onLanguageSelected = onLanguageSelected, onPollClicked = onPollClicked, onEmojiPick = { onContentChanged( TextFieldUtils.insertText( value = uiState.content, insertText = " :${it.shortcode}: ", ) ) }, onMentionClick = { onContentChanged( MentionTextUtil.insertMention( text = uiState.content, insertText = it.acct, ) ) }, onDeleteEmojiClick = { onContentChanged(DeleteTextUtil.deleteText(uiState.content)) }, ) }, attachment = { style -> StatusAttachment( modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp), uiState = uiState, style = style, onDeleteClick = onDeleteClick, onDescriptionInputted = onDescriptionInputted, onPollContentChanged = onPollContentChanged, onRemovePollClick = onRemovePollClick, onRemovePollItemClick = onRemovePollItemClick, onAddPollItemClick = onAddPollItemClick, onPollStyleSelect = onPollStyleSelect, onDurationSelect = onDurationSelect, ) }, ) if (showAccountSwitchPopup) { SelectAccountDialog( accountList = uiState.availableAccountList, onDismissRequest = { showAccountSwitchPopup = false }, selectedAccounts = listOf(uiState.account), onAccountClicked = { account -> onSwitchAccount(account) }, ) } } @Composable private fun StatusAttachment( modifier: Modifier, uiState: PostStatusUiState, style: PublishBlogStyle, onDeleteClick: (PublishPostMedia) -> Unit, onDescriptionInputted: (PublishPostMedia, String) -> Unit, onPollContentChanged: (Int, String) -> Unit, onRemovePollClick: () -> Unit, onRemovePollItemClick: (Int) -> Unit, onAddPollItemClick: () -> Unit, onPollStyleSelect: (multiple: Boolean) -> Unit, onDurationSelect: (Duration) -> Unit, ) { if (uiState.quotingBlog != null) { BlogInQuoting( modifier = Modifier.fillMaxWidth().padding(16.dp), blog = uiState.quotingBlog, style = style.statusStyle, ) } else if (uiState.unavailableQuote != null) { UnavailableQuoteInEmbedding( modifier = Modifier.fillMaxWidth() .padding(16.dp) .embedBorder() .padding(16.dp), unavailableQuote = uiState.unavailableQuote, onContentClick = {}, ) } else { val attachment = uiState.attachment ?: return when (attachment) { is PostStatusAttachment.Image -> { PublishPostMediaAttachment( modifier = modifier .padding(horizontal = 16.dp), medias = attachment.imageList, mediaAltMaxCharacters = uiState.rules.altMaxCharacters, onAltChanged = onDescriptionInputted, onDeleteClick = onDeleteClick, ) } is PostStatusAttachment.Video -> { PublishPostMediaAttachment( modifier = modifier .padding(horizontal = 16.dp), medias = listOf(attachment.video), mediaAltMaxCharacters = uiState.rules.altMaxCharacters, onAltChanged = onDescriptionInputted, onDeleteClick = onDeleteClick, ) } is PostStatusAttachment.Poll -> { PostStatusPoll( modifier = modifier, poll = attachment, rules = uiState.rules, onPollContentChanged = onPollContentChanged, onRemovePollClick = onRemovePollClick, onRemoveItemClick = onRemovePollItemClick, onAddPollItemClick = onAddPollItemClick, onPollStyleSelect = onPollStyleSelect, onDurationSelect = onDurationSelect, ) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/status/post/PostStatusScreenRoute.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.status.post import androidx.navigation3.runtime.NavKey import com.zhangke.framework.architect.json.fromJson import com.zhangke.framework.architect.json.globalJson import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.uri.FormalUri import kotlinx.serialization.json.Json import kotlinx.serialization.serializer object PostStatusScreenRoute { fun buildReplyScreen( accountUri: FormalUri, blog: Blog, ): NavKey { return PostStatusScreenKey( accountUri = accountUri, replyingBlogJsonString = globalJson.encodeToString(serializer(), blog) ) } fun buildEditBlogRoute( accountUri: FormalUri, blog: Blog, ): NavKey { return PostStatusScreenKey( accountUri = accountUri, editBlogJsonString = globalJson.encodeToString(serializer(), blog), ) } fun buildQuoteBlogScreen( accountUri: FormalUri, quoteBlog: Blog, ): NavKey { return PostStatusScreenKey( accountUri = accountUri, quoteBlogJsonString = globalJson.encodeToString(serializer(), quoteBlog), ) } fun buildParams( accountUri: FormalUri, defaultContent: String?, editBlog: String?, replyToBlogJsonString: String?, quoteBlogJsonString: String? = null, ): PostStatusScreenParams { val replyToBlog = replyToBlogJsonString?.let { runCatching { globalJson.fromJson(it) }.getOrNull() } if (replyToBlog != null) { return PostStatusScreenParams.ReplyStatusParams( accountUri = accountUri, replyingToBlog = replyToBlog, ) } if (!editBlog.isNullOrEmpty()) { val blog = runCatching { Json.decodeFromString(editBlog) }.getOrNull() if (blog != null) { return PostStatusScreenParams.EditStatusParams(accountUri, blog) } } if (!quoteBlogJsonString.isNullOrEmpty()) { val quoteBlog = runCatching { Json.decodeFromString(quoteBlogJsonString) }.getOrNull() if (quoteBlog != null) { return PostStatusScreenParams.QuoteBlogParams(accountUri, quoteBlog) } } return PostStatusScreenParams.PostStatusParams(accountUri, defaultContent) } } sealed interface PostStatusScreenParams { val accountUri: FormalUri? data class PostStatusParams( override val accountUri: FormalUri?, val defaultContent: String?, ) : PostStatusScreenParams data class ReplyStatusParams( override val accountUri: FormalUri?, val replyingToBlog: Blog, ) : PostStatusScreenParams data class EditStatusParams( override val accountUri: FormalUri?, val blog: Blog, ) : PostStatusScreenParams data class QuoteBlogParams( override val accountUri: FormalUri?, val quoteBlog: Blog, ) : PostStatusScreenParams } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/status/post/PostStatusUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.status.post import androidx.compose.ui.text.input.TextFieldValue import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.framework.composable.LoadableState import com.zhangke.framework.utils.ContentProviderFile import com.zhangke.framework.utils.Locale import com.zhangke.framework.utils.getDefaultLocale import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.screen.status.post.composable.GroupedCustomEmojiCell import com.zhangke.fread.commonbiz.shared.screen.publish.PublishPostMedia import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.blog.BlogEmbed import com.zhangke.fread.status.model.QuoteApprovalPolicy import com.zhangke.fread.status.model.StatusVisibility import kotlin.time.Duration data class PostStatusUiState( val account: ActivityPubLoggedAccount, val availableAccountList: List, val accountChangeable: Boolean, val content: TextFieldValue, val attachment: PostStatusAttachment?, val visibility: StatusVisibility, val visibilityChangeable: Boolean, val quoteApprovalPolicyChangeable: Boolean, val sensitive: Boolean, val warningContent: TextFieldValue, val replyToBlog: Blog?, val quotingBlog: Blog?, val unavailableQuote: BlogEmbed.UnavailableQuote?, val emojiList: List, val language: Locale, val rules: PostBlogRules, val publishing: Boolean, val mentionState: LoadableState>, val quoteApprovalPolicy: QuoteApprovalPolicy, ) { val showAddAccountIcon: Boolean get() = accountChangeable && replyToBlog == null val isQuotingBlogMode: Boolean get() = quotingBlog != null || unavailableQuote != null val publishEnabled: Boolean get() { if (publishing) return false if (content.text.isEmpty() && attachment == null) return false return true } fun hasInputtedData(): Boolean { if (content.text.isNotEmpty()) return true if (attachment != null) return true if (sensitive && warningContent.text.isNotEmpty()) return true return false } companion object { fun default( account: ActivityPubLoggedAccount, allLoggedAccount: List, visibility: StatusVisibility, replyingToBlog: Blog? = null, quoteBlog: Blog? = null, content: TextFieldValue = TextFieldValue(""), sensitive: Boolean = false, warningContent: TextFieldValue = TextFieldValue(""), language: Locale? = null, accountChangeable: Boolean = true, visibilityChangeable: Boolean = true, attachment: PostStatusAttachment? = null, quoteApprovalPolicyChangeable: Boolean = true, unavailableQuote: BlogEmbed.UnavailableQuote? = null, ): PostStatusUiState { return PostStatusUiState( account = account, availableAccountList = allLoggedAccount, content = content, attachment = attachment, visibility = visibility, sensitive = sensitive, replyToBlog = replyingToBlog, quotingBlog = quoteBlog, warningContent = warningContent, emojiList = emptyList(), language = language ?: getDefaultLocale(), rules = PostBlogRules.default(), accountChangeable = accountChangeable, visibilityChangeable = visibilityChangeable, publishing = false, mentionState = LoadableState.idle(), quoteApprovalPolicy = QuoteApprovalPolicy.PUBLIC, quoteApprovalPolicyChangeable = quoteApprovalPolicyChangeable, unavailableQuote = unavailableQuote, ) } } } sealed interface PostStatusAttachment { data class Image(val imageList: List) : PostStatusAttachment data class Video(val video: PostStatusMediaAttachmentFile) : PostStatusAttachment data class Poll( val optionList: List, val multiple: Boolean, val duration: Duration, ) : PostStatusAttachment val asImageOrNull: Image? get() = this as? Image val asVideoOrNull: Video? get() = this as? Video val asPollAttachment: Poll get() = this as Poll val asPollAttachmentOrNull: Poll? get() = this as? Poll } sealed interface PostStatusMediaAttachmentFile : PublishPostMedia { val previewUri: String data class LocalFile( val file: ContentProviderFile, override val alt: String?, ) : PostStatusMediaAttachmentFile { override val isVideo: Boolean get() = file.isVideo override val previewUri: String get() = file.uri.toString() override val uri: String get() = file.uri.toString() } data class RemoteFile( val id: String, val url: String, val originalAlt: String?, override val alt: String?, override val isVideo: Boolean, ) : PostStatusMediaAttachmentFile { override val previewUri: String get() = url override val uri: String get() = url } } data class PostBlogRules( val maxCharacters: Int, val maxMediaCount: Int, val maxPollOptions: Int, val altMaxCharacters: Int, val supportsQuotePost: Boolean, ) { companion object { fun default( maxCharacters: Int = 1000, maxMediaCount: Int = 4, maxPollOptions: Int = 4, altMaxCharacters: Int = 1500, supportsQuotePost: Boolean = false, ): PostBlogRules { return PostBlogRules( maxCharacters = maxCharacters, maxMediaCount = maxMediaCount, maxPollOptions = maxPollOptions, altMaxCharacters = altMaxCharacters, supportsQuotePost = supportsQuotePost, ) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/status/post/PostStatusViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.status.post import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zhangke.activitypub.entities.ActivityPubPreferencesEntity import com.zhangke.framework.collections.remove import com.zhangke.framework.collections.removeIndex import com.zhangke.framework.collections.updateIndex import com.zhangke.framework.composable.LoadableState import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.emitInViewModel import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.composable.requireSuccessData import com.zhangke.framework.composable.successDataOrNull import com.zhangke.framework.composable.textOf import com.zhangke.framework.composable.updateOnSuccess import com.zhangke.framework.composable.updateToFailed import com.zhangke.framework.coroutines.invokeOnCancel import com.zhangke.framework.ktx.ifNullOrEmpty import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.utils.ContentProviderFile import com.zhangke.framework.utils.Locale import com.zhangke.framework.utils.PlatformUri import com.zhangke.framework.utils.initLocale import com.zhangke.fread.activitypub.app.internal.adapter.toQuoteApprovalPolicy import com.zhangke.fread.activitypub.app.internal.adapter.toStatusVisibility import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.screen.status.post.usecase.GenerateInitPostStatusUiStateUseCase import com.zhangke.fread.activitypub.app.internal.screen.status.post.usecase.PublishPostUseCase import com.zhangke.fread.activitypub.app.internal.usecase.emoji.GetCustomEmojiUseCase import com.zhangke.fread.activitypub.app.internal.usecase.platform.GetInstancePostStatusRulesUseCase import com.zhangke.fread.common.utils.MentionTextUtil import com.zhangke.fread.common.utils.PlatformUriHelper import com.zhangke.fread.commonbiz.shared.screen.publish.PublishPostMedia import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.QuoteApprovalPolicy import com.zhangke.fread.status.model.StatusVisibility import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import kotlin.time.Duration import kotlin.time.Duration.Companion.days class PostStatusViewModel ( private val getCustomEmoji: GetCustomEmojiUseCase, private val getInstancePostStatusRules: GetInstancePostStatusRulesUseCase, private val generateInitPostStatusUiState: GenerateInitPostStatusUiStateUseCase, private val clientManager: ActivityPubClientManager, private val publishPost: PublishPostUseCase, private val screenParams: PostStatusScreenParams, private val platformUriHelper: PlatformUriHelper, ) : ViewModel() { private val _uiState = MutableStateFlow(LoadableState.loading()) val uiState: StateFlow> = _uiState.asStateFlow() private val _snackMessage = MutableSharedFlow() val snackMessage: SharedFlow get() = _snackMessage private val _publishSuccessFlow = MutableSharedFlow() val publishSuccessFlow: SharedFlow get() = _publishSuccessFlow private var searchMentionUserJob: Job? = null init { launchInViewModel { generateInitPostStatusUiState(screenParams) .onSuccess { _uiState.value = LoadableState.success(it) }.onFailure { _uiState.updateToFailed(it) } } loadPostingSettings() } private fun loadPostingSettings() { launchInViewModel { _uiState.mapNotNull { it.successDataOrNull()?.account } .distinctUntilChanged() .mapNotNull { it.locator } .collect { locator -> getCustomEmoji(locator) .onSuccess { _uiState.updateOnSuccess { state -> state.copy(emojiList = it) } }.onFailure { _snackMessage.emitTextMessageFromThrowable(it) } getInstancePostStatusRules(locator) .onSuccess { _uiState.updateOnSuccess { state -> state.copy(rules = it) } }.onFailure { _snackMessage.emitTextMessageFromThrowable(it) } if (screenParams !is PostStatusScreenParams.EditStatusParams) { clientManager.getClient(locator) .accountRepo .getPreferences() .onSuccess { preferences -> _uiState.updateOnSuccess { state -> fillDefaultSetting(state, preferences) } } } } } } private fun fillDefaultSetting( state: PostStatusUiState, preferences: ActivityPubPreferencesEntity, ): PostStatusUiState { val keepInitVisibility = state.replyToBlog != null || state.isQuotingBlogMode return state.copy( visibility = if (keepInitVisibility) { state.visibility } else { preferences.postingDefaultVisibility.toStatusVisibility() }, quoteApprovalPolicy = preferences.postingDefaultQuotePolicy?.toQuoteApprovalPolicy() ?: state.quoteApprovalPolicy, language = preferences.postingDefaultLanguage?.let { initLocale(it) } ?: state.language, ) } fun onSwitchAccountClick(account: LoggedAccount) { _uiState.updateOnSuccess { it.copy(account = account as ActivityPubLoggedAccount) } } fun onContentChanged(inputtedText: TextFieldValue) { _uiState.updateOnSuccess { it.copy(content = inputtedText) } maybeSearchAccountForMention(inputtedText) } private fun maybeSearchAccountForMention(content: TextFieldValue) { val account = _uiState.value.successDataOrNull()?.account ?: return searchMentionUserJob?.cancel() val platformLocator = PlatformLocator(baseUrl = account.platform.baseUrl, accountUri = account.uri) val mentionText = MentionTextUtil.findTypingMentionName(content)?.removePrefix("@") if (mentionText == null || mentionText.length < 2) { _uiState.updateOnSuccess { it.copy(mentionState = LoadableState.idle()) } return } searchMentionUserJob = launchInViewModel { _uiState.updateOnSuccess { it.copy(mentionState = LoadableState.loading()) } clientManager.getClient(locator = platformLocator) .accountRepo .search( query = mentionText, limit = 10, resolve = false, ) .onSuccess { list -> _uiState.updateOnSuccess { it.copy(mentionState = LoadableState.success(list)) } }.onFailure { _uiState.updateOnSuccess { it.copy(mentionState = LoadableState.idle()) } } } searchMentionUserJob?.invokeOnCancel { _uiState.updateOnSuccess { it.copy(mentionState = LoadableState.idle()) } } } fun onSensitiveClick() { _uiState.updateOnSuccess { it.copy(sensitive = !it.sensitive) } } fun onMediaSelected(list: List) { if (list.isEmpty()) return viewModelScope.launch { val fileList = list.map { async { platformUriHelper.read(it) } }.awaitAll().filterNotNull() val videoFile = fileList.firstOrNull { it.isVideo } if (videoFile != null) { onAddVideo(videoFile) } else { onAddImageList(fileList) } } } private fun onAddVideo(file: ContentProviderFile) { val attachmentFile = buildLocalAttachmentFile(file) _uiState.updateOnSuccess { it.copy(attachment = PostStatusAttachment.Video(attachmentFile)) } } private fun onAddImageList(uriList: List) { val imageList = mutableListOf() _uiState.value .requireSuccessData() .attachment ?.asImageOrNull ?.imageList ?.let { imageList += it } uriList.forEach { uri -> imageList += buildLocalAttachmentFile(uri) } _uiState.updateOnSuccess { it.copy(attachment = PostStatusAttachment.Image(imageList)) } } private fun buildLocalAttachmentFile( file: ContentProviderFile, ) = PostStatusMediaAttachmentFile.LocalFile( file = file, alt = null, ) fun onMediaDeleteClick(image: PublishPostMedia) { val attachment = _uiState.value .requireSuccessData() .attachment ?: return val imageAttachment = attachment.asImageOrNull if (imageAttachment != null) { _uiState.updateOnSuccess { state -> state.copy( attachment = PostStatusAttachment.Image(imageAttachment.imageList.remove { it == image }) ) } return } val videoAttachment = attachment.asVideoOrNull if (videoAttachment != null) { _uiState.updateOnSuccess { state -> state.copy(attachment = null) } } } fun onQuoteApprovalPolicySelect(policy: QuoteApprovalPolicy) { _uiState.updateOnSuccess { it.copy(quoteApprovalPolicy = policy) } } fun onDescriptionInputted(file: PublishPostMedia, description: String) { _uiState.updateOnSuccess { state -> val imageList = state.attachment?.asImageOrNull?.imageList ?: emptyList() val newImageList = imageList.map { if (it == file) { when (it) { is PostStatusMediaAttachmentFile.LocalFile -> it.copy(alt = description) is PostStatusMediaAttachmentFile.RemoteFile -> it.copy(alt = description) } } else { it } } state.copy(attachment = PostStatusAttachment.Image(newImageList)) } } fun onLanguageSelected(locale: Locale) { _uiState.updateOnSuccess { state -> state.copy(language = locale) } } fun onPollClicked() { if (_uiState.value.successDataOrNull()?.attachment is PostStatusAttachment.Poll) return _uiState.updateOnSuccess { state -> state.copy( attachment = PostStatusAttachment.Poll( optionList = listOf("", ""), multiple = false, duration = 1.days, ) ) } } fun onPollContentChanged(index: Int, content: String) { _uiState.updateOnSuccess { state -> val pollAttachment = state.attachment!!.asPollAttachment state.copy( attachment = pollAttachment.copy( optionList = pollAttachment.optionList.updateIndex(index) { content } ) ) } } fun onAddPollItemClick() { _uiState.updateOnSuccess { state -> val pollAttachment = state.attachment!!.asPollAttachment state.copy( attachment = pollAttachment.copy( optionList = pollAttachment.optionList.plus("") ) ) } } fun onRemovePollItemClick(index: Int) { _uiState.updateOnSuccess { state -> val pollAttachment = state.attachment!!.asPollAttachment state.copy( attachment = pollAttachment.copy( optionList = pollAttachment.optionList.removeIndex(index) ) ) } } fun onRemovePollClick() { _uiState.updateOnSuccess { state -> state.copy(attachment = null) } } fun onPollStyleSelect(multiple: Boolean) { _uiState.updateOnSuccess { state -> val poll = state.attachment!!.asPollAttachment state.copy( attachment = poll.copy( multiple = multiple ) ) } } fun onWarningContentChanged(content: TextFieldValue) { _uiState.updateOnSuccess { it.copy(warningContent = content) } } fun onVisibilityChanged(visibility: StatusVisibility) { _uiState.updateOnSuccess { it.copy(visibility = visibility) } } fun onDurationSelect(duration: Duration) { _uiState.updateOnSuccess { state -> val pollAttachment = state.attachment!!.asPollAttachment state.copy( attachment = pollAttachment.copy(duration = duration) ) } } fun onPostClick() { val currentUiState = _uiState.value.requireSuccessData() val account = currentUiState.account val attachment = currentUiState.attachment if (currentUiState.content.text.isEmpty() && currentUiState.attachment == null) { _snackMessage.emitInViewModel(textOf(LocalizedString.postStatusContentIsEmpty)) return } if (attachment is PostStatusAttachment.Poll) { if (currentUiState.content.text.isEmpty()) { _snackMessage.emitInViewModel(textOf(LocalizedString.postStatusContentIsEmpty)) return } for (option in attachment.optionList) { if (option.isEmpty()) { _snackMessage.emitInViewModel(textOf(LocalizedString.post_status_poll_is_empty)) return } } } launchInViewModel { _uiState.updateOnSuccess { it.copy(publishing = true) } publishPost( account = account, uiState = currentUiState, editingBlogId = (screenParams as? PostStatusScreenParams.EditStatusParams)?.blog?.id, ).onSuccess { _uiState.updateOnSuccess { it.copy(publishing = false) } _publishSuccessFlow.emit(Unit) }.onFailure { t -> _uiState.updateOnSuccess { it.copy(publishing = false) } val errorMessage = textOf( LocalizedString.postStatusFailed, t.message.ifNullOrEmpty { "unknown error" }.take(180), ) _snackMessage.emit(errorMessage) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/status/post/PublishInteractionSettingLabel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.status.post import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.outlined.Group import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.noRippleClick import com.zhangke.framework.composable.rememberTransientModalBottomSheetState import com.zhangke.fread.commonbiz.shared.screen.publish.PublishSettingLabel import com.zhangke.fread.commonbiz.shared.screen.publish.composable.InteractionOption import com.zhangke.fread.commonbiz.shared.screen.publish.multi.describeStringId import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.QuoteApprovalPolicy import com.zhangke.fread.status.model.StatusVisibility import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class) @Composable fun PublishInteractionSettingLabel( modifier: Modifier, visibility: StatusVisibility, visibilityChangeable: Boolean, quoteApprovalPolicy: QuoteApprovalPolicy, quoteApprovalPolicyChangeable: Boolean, onVisibilitySelect: (StatusVisibility) -> Unit, onQuoteApprovalPolicySelect: (QuoteApprovalPolicy) -> Unit, ) { var sheetVisibility by remember { mutableStateOf(false) } val noLimit = visibility == StatusVisibility.PUBLIC && quoteApprovalPolicy == QuoteApprovalPolicy.PUBLIC PublishSettingLabel( modifier = modifier.noRippleClick( enabled = visibilityChangeable || quoteApprovalPolicyChangeable, ) { sheetVisibility = true }, label = if (noLimit) { stringResource(LocalizedString.sharedPublishInteractionNoLimit) } else { stringResource(LocalizedString.sharedPublishInteractionLimited) }, icon = if (noLimit) Icons.Default.Public else Icons.Outlined.Group, ) val coroutineScope = rememberCoroutineScope() val state = rememberTransientModalBottomSheetState(skipPartiallyExpanded = true) if (sheetVisibility) { ModalBottomSheet( onDismissRequest = { coroutineScope.launch { state.hide() sheetVisibility = false } }, sheetState = state, ) { Column( modifier = Modifier.fillMaxWidth() .padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 32.dp) .verticalScroll(rememberScrollState()), ) { Text( modifier = Modifier.padding(top = 26.dp), text = stringResource(LocalizedString.sharedPublishInteractionDialogQuoteTitle), style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.SemiBold, ), ) InteractionOption( modifier = Modifier.padding(top = 16.dp).fillMaxWidth() .noRippleClick(enabled = quoteApprovalPolicyChangeable) { onQuoteApprovalPolicySelect(QuoteApprovalPolicy.PUBLIC) }, text = QuoteApprovalPolicy.PUBLIC.label, selected = quoteApprovalPolicy == QuoteApprovalPolicy.PUBLIC, ) InteractionOption( modifier = Modifier.padding(top = 16.dp).fillMaxWidth() .noRippleClick(enabled = quoteApprovalPolicyChangeable) { onQuoteApprovalPolicySelect(QuoteApprovalPolicy.FOLLOWERS) }, text = QuoteApprovalPolicy.FOLLOWERS.label, selected = quoteApprovalPolicy == QuoteApprovalPolicy.FOLLOWERS, ) InteractionOption( modifier = Modifier.padding(top = 16.dp).fillMaxWidth() .noRippleClick(enabled = quoteApprovalPolicyChangeable) { onQuoteApprovalPolicySelect(QuoteApprovalPolicy.NOBODY) }, text = QuoteApprovalPolicy.NOBODY.label, selected = quoteApprovalPolicy == QuoteApprovalPolicy.NOBODY, ) Text( modifier = Modifier.padding(top = 26.dp), text = stringResource(LocalizedString.status_ui_visibility), style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.SemiBold, ), ) InteractionOption( modifier = Modifier.padding(top = 16.dp).fillMaxWidth() .noRippleClick(enabled = visibilityChangeable) { onVisibilitySelect(StatusVisibility.PUBLIC) }, text = stringResource(StatusVisibility.PUBLIC.describeStringId), selected = visibility == StatusVisibility.PUBLIC, ) InteractionOption( modifier = Modifier.padding(top = 16.dp).fillMaxWidth() .noRippleClick(enabled = visibilityChangeable) { onVisibilitySelect(StatusVisibility.UNLISTED) }, text = stringResource(StatusVisibility.UNLISTED.describeStringId), selected = visibility == StatusVisibility.UNLISTED, ) InteractionOption( modifier = Modifier.padding(top = 16.dp).fillMaxWidth() .noRippleClick(enabled = visibilityChangeable) { onVisibilitySelect(StatusVisibility.PRIVATE) }, text = stringResource(StatusVisibility.PRIVATE.describeStringId), selected = visibility == StatusVisibility.PRIVATE, ) InteractionOption( modifier = Modifier.padding(top = 16.dp).fillMaxWidth() .noRippleClick(enabled = visibilityChangeable) { onVisibilitySelect(StatusVisibility.DIRECT) }, text = stringResource(StatusVisibility.DIRECT.describeStringId), selected = visibility == StatusVisibility.DIRECT, ) } } } } private val QuoteApprovalPolicy.label: String @Composable get() = when (this) { QuoteApprovalPolicy.PUBLIC -> stringResource(LocalizedString.status_ui_quote_approval_public) QuoteApprovalPolicy.FOLLOWERS -> stringResource(LocalizedString.status_ui_quote_approval_follower) QuoteApprovalPolicy.NOBODY -> stringResource(LocalizedString.status_ui_quote_approval_nobody) QuoteApprovalPolicy.FOLLOWING -> "Following" } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/status/post/adapter/CustomEmojiAdapter.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.status.post.adapter import com.zhangke.fread.activitypub.app.internal.model.CustomEmoji import com.zhangke.fread.activitypub.app.internal.screen.status.post.composable.GroupedCustomEmojiCell class CustomEmojiAdapter () { fun toEmojiCell( customEmojiList: List ): List { return customEmojiList.filter { it.visibleInPicker } .groupBy { it.category } .entries .map { GroupedCustomEmojiCell(it.key, it.value) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/status/post/composable/CustomEmojiPicker.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.status.post.composable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp import com.seiko.imageloader.ui.AutoSizeImage import com.zhangke.framework.composable.FreadTabRow import com.zhangke.framework.composable.icons.Tofu import com.zhangke.framework.composable.noRippleClick import com.zhangke.fread.activitypub.app.internal.model.CustomEmoji import kotlinx.coroutines.launch @Composable fun CustomEmojiPicker( modifier: Modifier, emojiList: List, onEmojiPick: (CustomEmoji) -> Unit, ) { if (emojiList.isEmpty()) return val coroutineScope = rememberCoroutineScope() Surface(modifier = modifier) { Column(modifier = Modifier.fillMaxWidth()) { val pagerState = rememberPagerState { emojiList.size } var selectedTabIndex by remember { mutableIntStateOf(0) } LaunchedEffect(pagerState.currentPage) { selectedTabIndex = pagerState.currentPage } FreadTabRow( selectedTabIndex = selectedTabIndex, tabCount = emojiList.size, tabContent = { Text(text = emojiList[it].title) }, onTabClick = { selectedTabIndex = it coroutineScope.launch { pagerState.animateScrollToPage(it) } }, ) HorizontalPager( modifier = Modifier .fillMaxWidth(), state = pagerState, ) { pageIndex -> CustomEmojiPickerPage( emojis = emojiList[pageIndex].emojiList, onEmojiClick = onEmojiPick, ) } } } } @Composable private fun CustomEmojiPickerPage( emojis: List, onEmojiClick: (CustomEmoji) -> Unit, ) { if (emojis.isEmpty()) return LazyVerticalGrid( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, columns = GridCells.Fixed(7), ) { items(emojis) { emoji -> Box( modifier = Modifier .padding(vertical = 8.dp) .noRippleClick { onEmojiClick(emoji) }, contentAlignment = Alignment.Center, ) { val placePainter = rememberVectorPainter(Icons.Default.Tofu) AutoSizeImage( emoji.url, modifier = Modifier.size(26.dp), errorPainter = { placePainter }, placeholderPainter = { placePainter }, contentDescription = emoji.shortcode, ) } } } } data class GroupedCustomEmojiCell( val title: String, val emojiList: List, ) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/status/post/composable/PostStatusBottomBar.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.status.post.composable import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box 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.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.EmojiEmotions import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Poll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text 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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import com.zhangke.framework.composable.BackHandler import androidx.compose.ui.unit.dp import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.freadPlaceholder import com.zhangke.framework.composable.requireSuccessData import com.zhangke.framework.utils.Locale import com.zhangke.framework.utils.PlatformUri import com.zhangke.framework.utils.initLocale import com.zhangke.framework.utils.languageCode import com.zhangke.fread.activitypub.app.internal.model.CustomEmoji import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusUiState import com.zhangke.fread.commonbiz.shared.screen.publish.PublishPostFeaturesPanel import com.zhangke.fread.commonbiz.shared.screen.publish.SensitiveIconButton import com.zhangke.fread.commonbiz.shared.screen.publish.bottomPaddingAsBottomBar import com.zhangke.fread.status.ui.BlogAuthorAvatar @OptIn(ExperimentalComposeUiApi::class) @Composable internal fun PostStatusBottomBar( uiState: PostStatusUiState, onSensitiveClick: () -> Unit, onPollClicked: () -> Unit, onMediaSelected: (List) -> Unit, onLanguageSelected: (Locale) -> Unit, onEmojiPick: (CustomEmoji) -> Unit, onMentionClick: (ActivityPubAccountEntity) -> Unit, onDeleteEmojiClick: () -> Unit, ) { var showEmojiPicker by remember { mutableStateOf(false) } PublishPostFeaturesPanel( modifier = Modifier.fillMaxWidth().bottomPaddingAsBottomBar(), contentLength = uiState.content.text.length, maxContentLimit = uiState.rules.maxCharacters, mediaAvailableCount = uiState.rules.maxMediaCount, onMediaSelected = onMediaSelected, selectedLanguages = listOf(uiState.language.languageCode), mediaSelectEnabled = !uiState.isQuotingBlogMode, maxLanguageCount = 1, onLanguageSelected = { onLanguageSelected(initLocale(it.first())) }, actions = { SimpleIconButton( modifier = Modifier .align(Alignment.CenterVertically) .padding(start = 4.dp), onClick = onPollClicked, enabled = !uiState.isQuotingBlogMode, imageVector = Icons.Default.Poll, contentDescription = "Add Poll", ) if (uiState.emojiList.isNotEmpty()) { SimpleIconButton( modifier = Modifier .align(Alignment.CenterVertically) .padding(start = 4.dp), onClick = { showEmojiPicker = true }, imageVector = Icons.Default.EmojiEmotions, contentDescription = "Pick Emoji", ) } SensitiveIconButton(onSensitiveClick = onSensitiveClick) }, floatingBar = { BottomBarMentions(uiState, onMentionClick) }, ) val bottomPaddingByIme = WindowInsets.ime.asPaddingValues().calculateBottomPadding() val bottomEmojiBarHeight = 48.dp AnimatedVisibility( visible = showEmojiPicker, modifier = Modifier.padding(bottom = bottomPaddingByIme), ) { BackHandler(showEmojiPicker) { showEmojiPicker = false } Box( modifier = Modifier .fillMaxWidth() .height(280.dp) ) { CustomEmojiPicker( modifier = Modifier .fillMaxSize() .padding(start = 16.dp, end = 16.dp, bottom = bottomEmojiBarHeight), emojiList = uiState.emojiList, onEmojiPick = onEmojiPick, ) Surface( modifier = Modifier .height(bottomEmojiBarHeight) .align(Alignment.BottomCenter) ) { Box( modifier = Modifier.fillMaxSize(), ) { SimpleIconButton( modifier = Modifier .padding(start = 16.dp) .align(Alignment.CenterStart), onClick = { showEmojiPicker = false }, imageVector = Icons.Default.KeyboardArrowDown, contentDescription = "Hide keyboard", ) SimpleIconButton( modifier = Modifier .padding(end = 16.dp) .align(Alignment.CenterEnd), onClick = onDeleteEmojiClick, imageVector = Icons.Default.Close, contentDescription = "Delete emoji", ) } } } } } @Composable private fun BottomBarMentions( uiState: PostStatusUiState, onMentionClick: (ActivityPubAccountEntity) -> Unit, ) { val mentionState = uiState.mentionState if (mentionState.isIdle || mentionState.isFailed) return Spacer(modifier = Modifier.height(8.dp)) if (mentionState.isLoading) { LazyRow( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(horizontal = 6.dp) ) { items(10) { MentionedItem(null, onMentionClick) Spacer(modifier = Modifier.width(8.dp)) } } } else if (mentionState.isSuccess) { LazyRow( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(horizontal = 6.dp) ) { items(mentionState.requireSuccessData()) { MentionedItem(it, onMentionClick) Spacer(modifier = Modifier.width(8.dp)) } } } Spacer(modifier = Modifier.height(8.dp)) } @Composable private fun MentionedItem( account: ActivityPubAccountEntity?, onMentionClick: (ActivityPubAccountEntity) -> Unit, ) { Row( modifier = Modifier .border( width = 1.dp, shape = RoundedCornerShape(12), color = MaterialTheme.colorScheme.outline, ) .clickable(account != null) { account?.let(onMentionClick) } .padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Spacer(modifier = Modifier.width(6.dp)) BlogAuthorAvatar( modifier = Modifier.size(18.dp), imageUrl = account?.avatar, ) Text( modifier = Modifier .padding(start = 4.dp) .widthIn(min = 80.dp) .freadPlaceholder(account?.acct.isNullOrEmpty()), text = account?.acct.orEmpty(), style = MaterialTheme.typography.labelMedium, ) Spacer(modifier = Modifier.width(6.dp)) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/status/post/composable/PostStatusPoll.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.status.post.composable import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Remove import androidx.compose.material3.DividerDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults 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 import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.zhangke.framework.composable.DurationSelector import com.zhangke.framework.composable.FreadDialog import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.utils.formattedString import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostBlogRules import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusAttachment import com.zhangke.fread.localization.LocalizedString import org.jetbrains.compose.resources.stringResource import kotlin.time.Duration @Composable internal fun PostStatusPoll( modifier: Modifier, poll: PostStatusAttachment.Poll, rules: PostBlogRules, onRemovePollClick: () -> Unit, onRemoveItemClick: (Int) -> Unit, onAddPollItemClick: () -> Unit, onPollContentChanged: (Int, String) -> Unit, onPollStyleSelect: (multiple: Boolean) -> Unit, onDurationSelect: (Duration) -> Unit, ) { Column(modifier = modifier.padding(start = 16.dp, end = 8.dp)) { poll.optionList.forEachIndexed { index, option -> Row(modifier = Modifier.fillMaxWidth()) { TextField( modifier = Modifier .weight(1F) .border( width = 1.dp, shape = RoundedCornerShape(4.dp), color = MaterialTheme.colorScheme.outline, ) .align(Alignment.CenterVertically), value = option, onValueChange = { onPollContentChanged(index, it) }, maxLines = 1, singleLine = true, placeholder = { Text( text = stringResource(LocalizedString.post_status_poll_item_hint, index + 1), style = MaterialTheme.typography.bodyMedium, ) }, textStyle = MaterialTheme.typography.bodyMedium, colors = TextFieldDefaults.colors( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, disabledIndicatorColor = Color.Transparent, errorIndicatorColor = Color.Transparent, focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, ), ) if (index == poll.optionList.lastIndex) { if (poll.optionList.size < rules.maxPollOptions) { SimpleIconButton( modifier = Modifier.align(Alignment.CenterVertically), onClick = onAddPollItemClick, imageVector = Icons.Rounded.Add, contentDescription = "", ) } } else { SimpleIconButton( modifier = Modifier.align(Alignment.CenterVertically), onClick = { onRemoveItemClick(index) }, imageVector = Icons.Rounded.Remove, enabled = poll.optionList.size > 2, contentDescription = "", ) } } Spacer( modifier = Modifier .fillMaxWidth() .height(6.dp) ) } Row( modifier = Modifier .padding(start = 8.dp) .fillMaxWidth() .height(38.dp) ) { var durationDialogVisible by remember { mutableStateOf(false) } Column( modifier = Modifier .fillMaxHeight() .clickable { durationDialogVisible = true } ) { Text( text = stringResource(LocalizedString.post_status_poll_duration), style = MaterialTheme.typography.labelMedium, ) Box(modifier = Modifier.weight(1F)) val durationString by produceState("", poll.duration) { value = poll.duration.formattedString() } Text( modifier = Modifier.padding(top = 2.dp), text = durationString, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, ) } if (durationDialogVisible) { DurationSelector( defaultDuration = poll.duration, onDismissRequest = { durationDialogVisible = false }, onDurationSelect = onDurationSelect, ) } Box( modifier = Modifier .padding(start = 8.dp, top = 6.dp, end = 16.dp, bottom = 4.dp) .width(1.dp) .fillMaxHeight() .background(DividerDefaults.color) ) var showChooseStyleDialog by remember { mutableStateOf(false) } Column(modifier = Modifier .fillMaxHeight() .clickable { showChooseStyleDialog = true }) { Text( text = stringResource(LocalizedString.post_status_poll_function_title), style = MaterialTheme.typography.labelSmall, ) Box(modifier = Modifier.weight(1F)) Text( modifier = Modifier.padding(top = 2.dp), text = if (poll.multiple) { stringResource(LocalizedString.post_status_poll_multiple) } else { stringResource(LocalizedString.post_status_poll_single) }, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, ) } if (showChooseStyleDialog) { ChoosePollStyleDialog( defaultMultiple = poll.multiple, onDismissRequest = { showChooseStyleDialog = false }, onSelect = onPollStyleSelect, ) } Box( modifier = Modifier .height(1.dp) .weight(1F) ) SimpleIconButton( modifier = Modifier.align(Alignment.CenterVertically), onClick = onRemovePollClick, imageVector = Icons.Filled.Delete, contentDescription = "Remove poll", ) } } } @Composable private fun ChoosePollStyleDialog( defaultMultiple: Boolean, onDismissRequest: () -> Unit, onSelect: (multiple: Boolean) -> Unit, ) { var multiple by remember(defaultMultiple) { mutableStateOf(defaultMultiple) } FreadDialog( onDismissRequest = onDismissRequest, title = stringResource(LocalizedString.post_status_poll_style_select_dialog_title), onNegativeClick = onDismissRequest, onPositiveClick = { onDismissRequest() onSelect(multiple) }, content = { Column( modifier = Modifier .padding(start = 80.dp, top = 16.dp, end = 80.dp, bottom = 16.dp) .fillMaxWidth() ) { Row(modifier = Modifier.fillMaxWidth()) { Text( modifier = Modifier.align(Alignment.CenterVertically), text = stringResource(LocalizedString.post_status_poll_single) ) Box(modifier = Modifier.weight(1F)) RadioButton( modifier = Modifier.align(Alignment.CenterVertically), selected = !multiple, onClick = { multiple = false }, ) } Row( modifier = Modifier .padding(top = 16.dp) .fillMaxWidth() ) { Text( modifier = Modifier.align(Alignment.CenterVertically), text = stringResource(LocalizedString.post_status_poll_multiple) ) Box(modifier = Modifier.weight(1F)) RadioButton( modifier = Modifier.align(Alignment.CenterVertically), selected = multiple, onClick = { multiple = true }, ) } } } ) } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/status/post/usecase/GenerateInitPostStatusUiStateUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.status.post.usecase import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import com.zhangke.framework.date.DateParser import com.zhangke.framework.ktx.ifNullOrEmpty import com.zhangke.framework.utils.initLocale import com.zhangke.fread.activitypub.app.ActivityPubAccountManager import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusAttachment import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusMediaAttachmentFile import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusScreenParams import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusUiState import com.zhangke.fread.common.utils.getCurrentTimeMillis import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.blog.BlogEmbed import com.zhangke.fread.status.blog.BlogMedia import com.zhangke.fread.status.blog.BlogMediaType import com.zhangke.fread.status.model.StatusVisibility import com.zhangke.fread.status.richtext.parser.HtmlParser import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds import kotlin.time.ExperimentalTime class GenerateInitPostStatusUiStateUseCase ( private val accountManager: ActivityPubAccountManager, ) { suspend operator fun invoke( screenParams: PostStatusScreenParams, ): Result { val allLoggedAccount = accountManager.getAllLoggedAccount() val defaultAccount = allLoggedAccount.pickDefaultAccount(screenParams) ?: return Result.failure(IllegalStateException("Not login!")) return when (screenParams) { is PostStatusScreenParams.PostStatusParams -> PostStatusUiState.default( account = defaultAccount, allLoggedAccount = allLoggedAccount, visibility = StatusVisibility.PUBLIC, replyingToBlog = null, content = if (screenParams.defaultContent.isNullOrEmpty()) { TextFieldValue("") } else { TextFieldValue( text = screenParams.defaultContent, selection = TextRange(screenParams.defaultContent.length), ) }, ) is PostStatusScreenParams.ReplyStatusParams -> buildReplyUiState( allLoggedAccount = allLoggedAccount, defaultAccount = defaultAccount, replyParams = screenParams, ) is PostStatusScreenParams.EditStatusParams -> buildEditPostUiState( defaultAccount = defaultAccount, allLoggedAccount = allLoggedAccount, editParams = screenParams, ) is PostStatusScreenParams.QuoteBlogParams -> buildQuotingPostUiState( defaultAccount = defaultAccount, allLoggedAccount = allLoggedAccount, quotingParams = screenParams, ) }.let { Result.success(it) } } private fun List.pickDefaultAccount( screenParams: PostStatusScreenParams ): ActivityPubLoggedAccount? { return if (screenParams.accountUri != null) { this.firstOrNull { it.uri == screenParams.accountUri } ?: this.firstOrNull() } else { this.firstOrNull() } } private fun buildReplyUiState( defaultAccount: ActivityPubLoggedAccount, allLoggedAccount: List, replyParams: PostStatusScreenParams.ReplyStatusParams, ): PostStatusUiState { val replyWebFinger = replyParams.replyingToBlog.author.webFinger val initialContent = if (defaultAccount.platform.baseUrl.host == replyWebFinger.host) { "@${replyWebFinger.name} " } else { "$replyWebFinger " } return PostStatusUiState.default( account = defaultAccount, allLoggedAccount = allLoggedAccount, content = buildTextFieldValue(initialContent), visibility = replyParams.replyingToBlog.visibility, replyingToBlog = replyParams.replyingToBlog, accountChangeable = false, quoteBlog = null, ) } private fun buildEditPostUiState( defaultAccount: ActivityPubLoggedAccount, allLoggedAccount: List, editParams: PostStatusScreenParams.EditStatusParams, ): PostStatusUiState { val blog = editParams.blog val quotingBlog = blog.embeds.firstNotNullOfOrNull { it as? BlogEmbed.Blog }?.blog return PostStatusUiState.default( account = defaultAccount, allLoggedAccount = allLoggedAccount, content = buildTextFieldValue(HtmlParser.parseToPlainText(blog.content)), visibility = blog.visibility, sensitive = editParams.blog.sensitive, language = editParams.blog.language?.let { initLocale(it) }, warningContent = buildTextFieldValue(HtmlParser.parseToPlainText(editParams.blog.spoilerText)), replyingToBlog = null, visibilityChangeable = false, accountChangeable = false, quoteApprovalPolicyChangeable = false, attachment = blog.generateAttachment(), quoteBlog = quotingBlog, unavailableQuote = blog.embeds.firstNotNullOfOrNull { it as? BlogEmbed.UnavailableQuote }, ) } private fun buildQuotingPostUiState( defaultAccount: ActivityPubLoggedAccount, allLoggedAccount: List, quotingParams: PostStatusScreenParams.QuoteBlogParams, ): PostStatusUiState { return PostStatusUiState.default( account = defaultAccount, allLoggedAccount = allLoggedAccount, content = buildTextFieldValue(""), visibility = StatusVisibility.PUBLIC, quoteBlog = quotingParams.quoteBlog, replyingToBlog = null, visibilityChangeable = true, accountChangeable = false, ) } @OptIn(ExperimentalTime::class) private fun Blog.generateAttachment(): PostStatusAttachment? { if (mediaList.isNotEmpty()) { if (mediaList.first().type == BlogMediaType.VIDEO) { return PostStatusAttachment.Video(mediaList.first().toAttachmentFile()) } return PostStatusAttachment.Image( mediaList.map { it.toAttachmentFile() } ) } val poll = poll if (poll != null) { val duration = poll.expiresAt ?.let { DateParser.parseAll(it) } ?.toEpochMilliseconds() ?.let { it - getCurrentTimeMillis() } ?.takeIf { it > 0 } ?.milliseconds return PostStatusAttachment.Poll( optionList = poll.options.map { it.title }, multiple = poll.multiple, duration = duration ?: 1.days, ) } return null } private fun BlogMedia.toAttachmentFile(): PostStatusMediaAttachmentFile.RemoteFile { return PostStatusMediaAttachmentFile.RemoteFile( id = this.id, url = this.previewUrl.ifNullOrEmpty { this.url }, alt = this.description, originalAlt = this.description, isVideo = this.type == BlogMediaType.VIDEO, ) } private fun buildTextFieldValue(text: String): TextFieldValue { return TextFieldValue( text = text, selection = TextRange(text.length) ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/status/post/usecase/PublishPostUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.status.post.usecase import com.zhangke.activitypub.entities.ActivityPubEditStatusEntity import com.zhangke.activitypub.entities.ActivityPubPostStatusRequestEntity import com.zhangke.activitypub.entities.ActivityPubStatusVisibilityEntity import com.zhangke.framework.utils.Locale import com.zhangke.framework.utils.isO3LanguageCode import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.adapter.PostStatusAttachmentAdapter import com.zhangke.fread.activitypub.app.internal.adapter.apCode import com.zhangke.fread.activitypub.app.internal.adapter.toEntityVisibility import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusAttachment import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusMediaAttachmentFile import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostStatusUiState import com.zhangke.fread.activitypub.app.internal.usecase.media.UploadMediaAttachmentUseCase import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.QuoteApprovalPolicy import com.zhangke.fread.status.model.StatusVisibility import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.supervisorScope class PublishPostUseCase ( private val clientManager: ActivityPubClientManager, private val uploadMediaAttachment: UploadMediaAttachmentUseCase, private val attachmentAdapter: PostStatusAttachmentAdapter, private val statusUpdater: StatusUpdater, private val statusEntityAdapter: ActivityPubStatusAdapter, ) { suspend operator fun invoke( account: ActivityPubLoggedAccount, content: String, attachment: PostStatusAttachment?, sensitive: Boolean, warningContent: String?, visibility: StatusVisibility, language: Locale, replyToBlogId: String? = null, editingBlogId: String? = null, quotingBlogId: String? = null, quoteApprovalPolicy: QuoteApprovalPolicy? = null, ): Result { val locator = account.locator val statusRepo = clientManager.getClient(locator).statusRepo val medias = handleMedias(locator, attachment).let { if (it.isFailure) return Result.failure(it.exceptionOrNull()!!) it.getOrThrow() } return if (!editingBlogId.isNullOrEmpty()) { statusRepo.editStatus( id = editingBlogId, status = content, mediaIds = medias, mediaAttributes = buildMediaAttributes(attachment), poll = attachment?.asPollAttachmentOrNull?.let(attachmentAdapter::toPollRequest), sensitive = sensitive, spoilerText = if (sensitive) warningContent else null, language = language.isO3LanguageCode, ) } else { val request = ActivityPubPostStatusRequestEntity( status = content, mediaIds = medias, poll = attachment?.asPollAttachmentOrNull?.let(attachmentAdapter::toPollRequest), isSensitive = sensitive, spoilerText = if (sensitive) warningContent else null, replyToId = replyToBlogId, visibility = visibility.toEntityVisibility().code, language = language.isO3LanguageCode, quotedStatusId = quotingBlogId, quoteApprovalPolicy = quoteApprovalPolicy?.apCode, ) statusRepo.publishBlog(request) }.map { statusUpdater.update( statusEntityAdapter.toStatusUiState( entity = it, platform = account.platform, locator = locator, loggedAccount = account, ) ) } } suspend operator fun invoke( account: ActivityPubLoggedAccount, uiState: PostStatusUiState, editingBlogId: String?, ): Result { return invoke( account = account, content = uiState.content.text, attachment = uiState.attachment, sensitive = uiState.sensitive, warningContent = uiState.warningContent.text, visibility = uiState.visibility, language = uiState.language, replyToBlogId = uiState.replyToBlog?.id, editingBlogId = editingBlogId, quotingBlogId = uiState.quotingBlog?.id, quoteApprovalPolicy = uiState.quoteApprovalPolicy, ) } private suspend fun handleMedias( locator: PlatformLocator, attachment: PostStatusAttachment?, ): Result> { if (attachment == null) return Result.success(emptyList()) val mediaIdList = mutableListOf() val localFiles = mutableListOf() if (attachment is PostStatusAttachment.Image) { for (file in attachment.imageList) { when (file) { is PostStatusMediaAttachmentFile.LocalFile -> localFiles.add(file) is PostStatusMediaAttachmentFile.RemoteFile -> mediaIdList.add(file.id) } } } else if (attachment is PostStatusAttachment.Video) { when (val file = attachment.video) { is PostStatusMediaAttachmentFile.LocalFile -> localFiles.add(file) is PostStatusMediaAttachmentFile.RemoteFile -> mediaIdList.add(file.id) } } if (localFiles.isNotEmpty()) { val uploadResultList = supervisorScope { localFiles.map { async { uploadMediaWithAlt(locator, it) } }.awaitAll() } if (uploadResultList.any { it.isFailure }) { return Result.failure(uploadResultList.first { it.isFailure }.exceptionOrNull()!!) } uploadResultList.map { it.getOrThrow() }.forEach { mediaIdList.add(it) } } return Result.success(mediaIdList) } private fun buildMediaAttributes( attachment: PostStatusAttachment?, ): List { if (attachment == null) return emptyList() val mediaAttributes = mutableListOf() val mediaFileList = when (attachment) { is PostStatusAttachment.Image -> { attachment.imageList } is PostStatusAttachment.Video -> { listOf(attachment.video) } else -> emptyList() } mediaFileList.mapNotNull { it as? PostStatusMediaAttachmentFile.RemoteFile }.map { mediaAttributes += ActivityPubEditStatusEntity.MediaAttributes( id = it.id, description = it.alt, ) } return mediaAttributes } private suspend fun uploadMediaWithAlt( locator: PlatformLocator, file: PostStatusMediaAttachmentFile.LocalFile, ): Result { return uploadMediaAttachment(locator, file.file) .mapCatching { mediaId -> if (file.alt.isNullOrEmpty()) { mediaId } else { updateAlt(locator, mediaId, file.alt).getOrThrow() mediaId } } } private suspend fun updateAlt( locator: PlatformLocator, fileId: String, alt: String?, ): Result { return clientManager.getClient(locator).mediaRepo .updateMedia(id = fileId, description = alt) .map { } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/trending/TrendingStatusSubViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.trending import com.zhangke.framework.lifecycle.SubViewModel import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.feeds.model.RefreshResult import com.zhangke.fread.common.status.StatusConfigurationDefault import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.feeds.FeedsViewModelController import com.zhangke.fread.commonbiz.shared.feeds.IFeedsViewModelController import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState class TrendingStatusSubViewModel( private val statusProvider: StatusProvider, private val clientManager: ActivityPubClientManager, statusUpdater: StatusUpdater, private val statusAdapter: ActivityPubStatusAdapter, statusUiStateAdapter: StatusUiStateAdapter, refactorToNewStatus: RefactorToNewStatusUseCase, private val platformRepo: ActivityPubPlatformRepo, private val locator: PlatformLocator, private val loggedAccountProvider: LoggedAccountProvider, ) : SubViewModel(), IFeedsViewModelController by FeedsViewModelController( statusProvider = statusProvider, statusUpdater = statusUpdater, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, ) { init { initController( coroutineScope = viewModelScope, locatorResolver = { locator }, loadFirstPageLocalFeeds = ::loadFirstPageLocalFeeds, loadNewFromServerFunction = ::loadNewFromServer, loadMoreFunction = ::loadMore, onStatusUpdate = {}, ) initFeeds(false) } private fun loadFirstPageLocalFeeds(): Result> { return Result.success(emptyList()) } private suspend fun loadNewFromServer(): Result { return getServerTrending(0).map { RefreshResult( newStatus = it, deletedStatus = emptyList(), useOldData = false, ) } } private suspend fun loadMore(maxId: String): Result> { val offset = uiState.value.feeds.size if (offset == 0) return Result.success(emptyList()) return getServerTrending(offset) } private suspend fun getServerTrending(offset: Int): Result> { val platformResult = platformRepo.getPlatform(locator) if (platformResult.isFailure) { return Result.failure(platformResult.exceptionOrNull()!!) } val platform = platformResult.getOrThrow() val loggedAccount = locator.accountUri?.let { loggedAccountProvider.getAccount(it) } return clientManager.getClient(locator) .instanceRepo .getTrendsStatuses( limit = StatusConfigurationDefault.config.loadFromServerLimit, offset = offset, ).map { list -> list.map { statusAdapter.toStatusUiState( entity = it, platform = platform, loggedAccount = loggedAccount, locator = locator, ) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/trending/TrendingStatusTab.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.trending import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.LocalSnackbarHostState import com.zhangke.framework.nav.BaseTab import com.zhangke.framework.nav.TabOptions import com.zhangke.fread.activitypub.app.internal.composable.ActivityPubTabNames import com.zhangke.fread.commonbiz.shared.composable.FeedsContent import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.ui.common.LocalNestedTabConnection import org.koin.compose.viewmodel.koinViewModel internal class TrendingStatusTab(private val locator: PlatformLocator) : BaseTab() { override val options: TabOptions @Composable get() = TabOptions( title = ActivityPubTabNames.trending ) @Composable override fun Content() { super.Content() val snackbarHostState = LocalSnackbarHostState.current val viewModel = koinViewModel().getSubViewModel(locator) val uiState by viewModel.uiState.collectAsState() val mainTabConnection = LocalNestedTabConnection.current val coroutineScope = rememberCoroutineScope() FeedsContent( uiState = uiState, openScreenFlow = viewModel.openScreenFlow, newStatusNotifyFlow = viewModel.newStatusNotifyFlow, composedStatusInteraction = viewModel.composedStatusInteraction, onRefresh = viewModel::onRefresh, onLoadMore = viewModel::onLoadMore, observeScrollToTopEvent = true, nestedScrollConnection = null, onImmersiveEvent = { if (it) { mainTabConnection.openImmersiveMode(coroutineScope) } else { mainTabConnection.closeImmersiveMode(coroutineScope) } }, onScrollInProgress = { mainTabConnection.updateContentScrollInProgress(it) }, ) LaunchedEffect(mainTabConnection.refreshFlow) { mainTabConnection.refreshFlow.collect { viewModel.onRefresh() } } ConsumeSnackbarFlow(snackbarHostState, viewModel.errorMessageFlow) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/trending/TrendingStatusViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.trending import com.zhangke.framework.lifecycle.ContainerViewModel import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.PlatformLocator class TrendingStatusViewModel ( private val statusProvider: StatusProvider, private val clientManager: ActivityPubClientManager, private val statusUpdater: StatusUpdater, private val statusAdapter: ActivityPubStatusAdapter, private val statusUiStateAdapter: StatusUiStateAdapter, private val platformRepo: ActivityPubPlatformRepo, private val refactorToNewStatus: RefactorToNewStatusUseCase, private val loggedAccountProvider: LoggedAccountProvider, ) : ContainerViewModel() { override fun createSubViewModel(params: Params): TrendingStatusSubViewModel { return TrendingStatusSubViewModel( statusProvider = statusProvider, clientManager = clientManager, statusUpdater = statusUpdater, statusAdapter = statusAdapter, refactorToNewStatus = refactorToNewStatus, statusUiStateAdapter = statusUiStateAdapter, platformRepo = platformRepo, loggedAccountProvider = loggedAccountProvider, locator = params.locator, ) } fun getSubViewModel( locator: PlatformLocator, ): TrendingStatusSubViewModel { val params = Params(locator) return obtainSubViewModel(params) } class Params(val locator: PlatformLocator) : SubViewModelParams() { override val key: String get() = locator.toString() } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/UserDetailContainerViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user import com.zhangke.framework.lifecycle.ContainerViewModel import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.activitypub.app.ActivityPubAccountManager import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubAccountEntityAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubCustomEmojiEntityAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.uri.UserUriTransformer import com.zhangke.fread.activitypub.app.internal.usecase.ActivityPubAccountLogoutUseCase import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.uri.FormalUri class UserDetailContainerViewModel ( private val accountManager: ActivityPubAccountManager, private val userUriTransformer: UserUriTransformer, private val clientManager: ActivityPubClientManager, private val accountEntityAdapter: ActivityPubAccountEntityAdapter, private val emojiEntityAdapter: ActivityPubCustomEmojiEntityAdapter, private val accountLogout: ActivityPubAccountLogoutUseCase, ) : ContainerViewModel() { override fun createSubViewModel(params: Params): UserDetailViewModel { return UserDetailViewModel( accountManager = accountManager, userUriTransformer = userUriTransformer, clientManager = clientManager, emojiEntityAdapter = emojiEntityAdapter, accountEntityAdapter = accountEntityAdapter, accountLogout = accountLogout, locator = params.locator, userUri = params.userUri, webFinger = params.webFinger, userId = params.userId, ) } fun getViewModel( locator: PlatformLocator, userUri: FormalUri?, webFinger: WebFinger?, userId: String?, ): UserDetailViewModel { return obtainSubViewModel(Params(locator, userUri, webFinger, userId)) } class Params( val locator: PlatformLocator, val userUri: FormalUri?, val webFinger: WebFinger?, val userId: String?, ) : SubViewModelParams() { override val key: String get() = locator.toString() + userUri + webFinger + userId } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/UserDetailScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.automirrored.filled.VolumeOff import androidx.compose.material.icons.automirrored.outlined.ListAlt import androidx.compose.material.icons.filled.AlternateEmail import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.Bookmarks import androidx.compose.material.icons.filled.Campaign import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FilterAlt import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuDefaults import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector 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.navigation3.runtime.NavKey import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.framework.composable.AlertConfirmDialog import com.zhangke.framework.composable.ConsumeFlow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.FreadDialog import com.zhangke.framework.composable.PopupMenu import com.zhangke.framework.composable.SimpleIconButton import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.composable.rememberTransientModalBottomSheetState import com.zhangke.framework.date.DateParser import com.zhangke.framework.nav.ContentPaddingsHorizontalPagerWithTab import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.Tab import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.activitypub.app.internal.screen.account.EditAccountInfoScreenNavKey import com.zhangke.fread.activitypub.app.internal.screen.filters.list.FiltersListScreenKey import com.zhangke.fread.activitypub.app.internal.screen.hashtag.HashtagTimelineScreenKey import com.zhangke.fread.activitypub.app.internal.screen.list.CreatedListsScreenKey import com.zhangke.fread.activitypub.app.internal.screen.search.SearchStatusScreenNavKey import com.zhangke.fread.activitypub.app.internal.screen.user.list.UserListScreenKey import com.zhangke.fread.activitypub.app.internal.screen.user.list.UserListType import com.zhangke.fread.activitypub.app.internal.screen.user.status.StatusListScreenKey import com.zhangke.fread.activitypub.app.internal.screen.user.status.StatusListTabStatusListScreen import com.zhangke.fread.activitypub.app.internal.screen.user.status.StatusListType import com.zhangke.fread.activitypub.app.internal.screen.user.tags.TagListScreenKey import com.zhangke.fread.activitypub.app.internal.screen.user.timeline.UserTimelineTab import com.zhangke.fread.activitypub.app.internal.screen.user.timeline.UserTimelineTabType import com.zhangke.fread.common.browser.LocalActivityBrowserLauncher import com.zhangke.fread.common.browser.launchWebTabInApp import com.zhangke.fread.common.handler.LocalTextHandler import com.zhangke.fread.common.utils.formatDate import com.zhangke.fread.commonbiz.shared.screen.ImageViewerImage import com.zhangke.fread.commonbiz.shared.screen.ImageViewerScreenNavKey import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.Emoji import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.Relationships import com.zhangke.fread.status.richtext.RichText import com.zhangke.fread.status.ui.action.DropDownCopyLinkItem import com.zhangke.fread.status.ui.action.DropDownOpenInBrowserItem import com.zhangke.fread.status.ui.action.DropDownOpenOriginalInstanceItem import com.zhangke.fread.status.ui.action.ModalDropdownMenuItem import com.zhangke.fread.status.ui.common.DetailPageScaffold import com.zhangke.fread.status.ui.common.LocalNestedTabConnection import com.zhangke.fread.status.ui.common.NestedTabConnection import com.zhangke.fread.status.ui.common.RelationshipStateButton import com.zhangke.fread.status.ui.common.UserFollowLine import com.zhangke.fread.status.ui.richtext.FreadRichText import com.zhangke.fread.status.ui.user.UserHandleLine import com.zhangke.fread.status.uri.FormalUri import com.zhangke.fread.statusui.ic_status_forward import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource @Serializable data class UserDetailScreenKey( val locator: PlatformLocator, val userUri: FormalUri? = null, val webFinger: WebFinger? = null, val userId: String? = null, ) : NavKey @Composable fun UserDetailScreen( viewModel: UserDetailContainerViewModel, locator: PlatformLocator, userUri: FormalUri? = null, webFinger: WebFinger? = null, userId: String? = null, ) { val backstack = LocalNavBackStack.currentOrThrow val browserLauncher = LocalActivityBrowserLauncher.current val activityTextHandler = LocalTextHandler.current val viewModel = viewModel.getViewModel(locator, userUri, webFinger, userId) val uiState by viewModel.uiState.collectAsState() val coroutineScope = rememberCoroutineScope() UserDetailContent( uiState = uiState, messageFlow = viewModel.messageFlow, userId = userId, onFavouritesClick = { backstack.add( StatusListScreenKey( locator = locator, type = StatusListType.FAVOURITES ) ) }, onSearchClick = { uiState.accountUiState?.account?.id?.let { userId -> backstack.add( SearchStatusScreenNavKey( locator = uiState.locator, userId = userId, ) ) } }, onBookmarksClick = { backstack.add(StatusListScreenKey(locator = locator, type = StatusListType.BOOKMARKS)) }, onBackClick = backstack::removeLastOrNull, onFollowAccountClick = viewModel::onFollowClick, onUnfollowAccountClick = viewModel::onUnfollowClick, onCancelFollowRequestClick = viewModel::onCancelFollowRequestClick, onUnblockClick = viewModel::onUnblockClick, onBlockClick = viewModel::onBlockClick, onBlockDomainClick = viewModel::onBlockDomainClick, onUnblockDomainClick = viewModel::onUnblockDomainClick, onAvatarClick = { uiState.accountUiState ?.account ?.avatar ?.let { val screenKey = ImageViewerScreenNavKey( selectedIndex = 0, imageList = listOf(ImageViewerImage(url = it)), ) backstack.add(screenKey) } }, onBannerClick = { uiState.accountUiState ?.account ?.header ?.let { val screen = ImageViewerScreenNavKey( selectedIndex = 0, imageList = listOf(ImageViewerImage(url = it)), ) backstack.add(screen) } }, onOpenInBrowserClick = { uiState.accountUiState?.account?.url?.let { browserLauncher.launchWebTabInApp( scope = coroutineScope, url = it, checkAppSupportPage = false, ) } }, onCopyLinkClick = { uiState.accountUiState?.account?.url?.let { activityTextHandler.copyText(it) } }, onOpenOriginalInstanceClick = { uiState.accountUiState?.account?.url?.let { FormalBaseUrl.parse(it) }?.let { browserLauncher.launchWebTabInApp( scope = coroutineScope, url = it.toString(), locator = locator, checkAppSupportPage = true, ) } }, onEditClick = { uiState.userInsight ?.let { backstack.add( EditAccountInfoScreenNavKey( baseUrl = it.baseUrl.toString(), accountUri = it.uri.toString(), ) ) } }, onFollowerClick = { if (uiState.userInsight != null) { val key = UserListScreenKey( type = UserListType.FOLLOWERS, locator = uiState.locator, userUri = uiState.userInsight!!.uri, userId = uiState.accountUiState?.account?.id ?: userId, ) backstack.add(key) } }, onFollowingClick = { if (uiState.userInsight != null) { val screen = UserListScreenKey( type = UserListType.FOLLOWING, locator = uiState.locator, userUri = uiState.userInsight!!.uri, userId = uiState.accountUiState?.account?.id ?: userId, ) backstack.add(screen) } }, onNewNoteSet = viewModel::onNewNoteSet, onMaybeHashtagClick = { backstack.add( HashtagTimelineScreenKey( locator = uiState.locator, hashtag = it.removePrefix("#"), ) ) }, onUnmuteUserClick = viewModel::onUnmuteUserClick, onMuteUserClick = viewModel::onMuteUserClick, onMuteUserListClick = { backstack.add( UserListScreenKey( locator = locator, type = UserListType.MUTED, userId = uiState.accountUiState?.account?.id ?: userId, ) ) }, onBlockedUserListClick = { backstack.add( UserListScreenKey( locator = locator, type = UserListType.BLOCKED, userId = uiState.accountUiState?.account?.id ?: userId, ) ) }, onFollowedHashtagsListClick = { backstack.add(TagListScreenKey(locator)) }, onFilterClick = { backstack.add(FiltersListScreenKey(uiState.locator)) }, onCreatedListClick = { backstack.add(CreatedListsScreenKey(uiState.locator)) }, onLogoutClick = { viewModel.onLogoutClick() }, ) ConsumeFlow(viewModel.finishPageFlow) { backstack.removeLastOrNull() } } @Composable private fun UserDetailContent( uiState: UserDetailUiState, messageFlow: SharedFlow, userId: String?, onBackClick: () -> Unit, onSearchClick: () -> Unit, onFavouritesClick: () -> Unit, onBookmarksClick: () -> Unit, onBannerClick: () -> Unit, onAvatarClick: () -> Unit, onMuteUserClick: () -> Unit, onUnmuteUserClick: () -> Unit, onUnblockClick: () -> Unit, onFollowAccountClick: () -> Unit, onUnfollowAccountClick: () -> Unit, onCancelFollowRequestClick: () -> Unit, onBlockClick: () -> Unit, onBlockDomainClick: () -> Unit, onUnblockDomainClick: () -> Unit, onOpenInBrowserClick: () -> Unit, onCopyLinkClick: () -> Unit, onOpenOriginalInstanceClick: () -> Unit, onEditClick: () -> Unit, onFollowerClick: () -> Unit, onFollowingClick: () -> Unit, onNewNoteSet: (String) -> Unit, onMaybeHashtagClick: (String) -> Unit, onMuteUserListClick: () -> Unit, onBlockedUserListClick: () -> Unit, onFollowedHashtagsListClick: () -> Unit, onFilterClick: () -> Unit, onCreatedListClick: () -> Unit, onLogoutClick: () -> Unit, ) { val accountUiState = uiState.accountUiState val account = accountUiState?.account val browserLauncher = LocalActivityBrowserLauncher.current val contentCanScrollBackward = remember { mutableStateOf(false) } val snackBarHost = rememberSnackbarHostState() val coroutineScope = rememberCoroutineScope() ConsumeSnackbarFlow(hostState = snackBarHost, messageTextFlow = messageFlow) DetailPageScaffold( modifier = Modifier.fillMaxSize(), snackbarHostState = snackBarHost, title = accountUiState?.userName ?: RichText.empty, avatar = account?.avatar.orEmpty(), banner = account?.header, description = accountUiState?.description, privateNote = uiState.personalNote, loading = uiState.loading, contentCanScrollBackward = contentCanScrollBackward, onBannerClick = onBannerClick, onAvatarClick = onAvatarClick, onUrlClick = { browserLauncher.launchWebTabInApp(coroutineScope, it, uiState.locator) }, onMaybeHashtagClick = onMaybeHashtagClick, onBackClick = onBackClick, topBarActions = { ToolbarActions( uiState = uiState, onFavouritesClick = onFavouritesClick, onBlockClick = onBlockClick, onSearchClick = onSearchClick, onBookmarksClick = onBookmarksClick, onBlockDomainClick = onBlockDomainClick, onUnblockDomainClick = onUnblockDomainClick, onOpenInBrowserClick = onOpenInBrowserClick, onOpenOriginalInstanceClick = onOpenOriginalInstanceClick, onNewNoteSet = onNewNoteSet, onCopyLinkClick = onCopyLinkClick, onMuteUserClick = onMuteUserClick, onUnmuteUserClick = onUnmuteUserClick, onMuteUserListClick = onMuteUserListClick, onBlockedUserListClick = onBlockedUserListClick, onFollowedHashtagsListClick = onFollowedHashtagsListClick, onFilterClick = onFilterClick, onCreatedListClick = onCreatedListClick, onLogoutClick = onLogoutClick, ) }, handleLine = { UserHandleLine( modifier = Modifier, handle = account?.prettyAcct.orEmpty(), bot = account?.bot == true, followedBy = uiState.relationships?.followedBy == true ) }, followInfoLine = { UserFollowLine( modifier = Modifier, followersCount = account?.followersCount?.toLong(), followingCount = account?.followingCount?.toLong(), statusesCount = account?.statusesCount?.toLong(), onFollowerClick = onFollowerClick, onFollowingClick = onFollowingClick, ) }, topDetailContentAction = { if (uiState.isAccountOwner) { FilledTonalButton( onClick = onEditClick, ) { Text( text = stringResource(LocalizedString.statusUiEditProfile) ) } } else if (uiState.relationships != null) { RelationshipStateButton( modifier = Modifier, relationship = uiState.relationships, onFollowClick = onFollowAccountClick, onUnfollowClick = onUnfollowAccountClick, onCancelFollowRequestClick = onCancelFollowRequestClick, onUnblockClick = onUnblockClick, ) } }, bottomArea = if (uiState.accountUiState?.account != null) { { UserAboutCard( locator = uiState.locator, account = uiState.accountUiState.account, emojis = uiState.accountUiState.emojis, ) } } else { null }, ) { progress -> if (uiState.userInsight != null) { val tabs: List = remember( uiState.userInsight, uiState.locator, uiState.isAccountOwner, ) { buildTabList( webFinger = uiState.userInsight.webFinger, locator = uiState.locator, userId = userId, isAccountOwner = uiState.isAccountOwner, contentCanScrollBackward = contentCanScrollBackward, ) } val nestedTabConnection = remember { NestedTabConnection() } CompositionLocalProvider( LocalNestedTabConnection provides nestedTabConnection, ) { val contentScrollInProgress by nestedTabConnection.contentScrollInpProgress.collectAsState() ContentPaddingsHorizontalPagerWithTab( tabList = tabs, blurEnabled = progress >= 1F, pagerUserScrollEnabled = !contentScrollInProgress, ) } } } } private fun buildTabList( locator: PlatformLocator, webFinger: WebFinger, isAccountOwner: Boolean, userId: String?, contentCanScrollBackward: MutableState, ): List { val tabList = mutableListOf() tabList += UserTimelineTab( tabType = UserTimelineTabType.POSTS, locator = locator, userWebFinger = webFinger, contentCanScrollBackward = contentCanScrollBackward, userId = userId, ) tabList += UserTimelineTab( tabType = UserTimelineTabType.REPLIES, locator = locator, userWebFinger = webFinger, contentCanScrollBackward = contentCanScrollBackward, userId = userId, ) tabList += UserTimelineTab( tabType = UserTimelineTabType.MEDIA, locator = locator, userWebFinger = webFinger, contentCanScrollBackward = contentCanScrollBackward, userId = userId, ) if (isAccountOwner) { tabList += StatusListTabStatusListScreen( locator = locator, type = StatusListType.FAVOURITES, contentCanScrollBackward = contentCanScrollBackward, ) tabList += StatusListTabStatusListScreen( locator = locator, type = StatusListType.BOOKMARKS, contentCanScrollBackward = contentCanScrollBackward, ) } return tabList } @Composable private fun ToolbarActions( uiState: UserDetailUiState, onSearchClick: () -> Unit, onFavouritesClick: () -> Unit, onBookmarksClick: () -> Unit, onBlockClick: () -> Unit, onBlockDomainClick: () -> Unit, onUnblockDomainClick: () -> Unit, onOpenInBrowserClick: () -> Unit, onCopyLinkClick: () -> Unit, onOpenOriginalInstanceClick: () -> Unit, onNewNoteSet: (String) -> Unit, onMuteUserClick: () -> Unit, onUnmuteUserClick: () -> Unit, onMuteUserListClick: () -> Unit, onBlockedUserListClick: () -> Unit, onFollowedHashtagsListClick: () -> Unit, onFilterClick: () -> Unit, onCreatedListClick: () -> Unit, onLogoutClick: () -> Unit, ) { val accountUiState = uiState.accountUiState ?: return SimpleIconButton( onClick = onSearchClick, imageVector = Icons.Default.Search, contentDescription = stringResource(LocalizedString.search), ) if (uiState.isAccountOwner) { SimpleIconButton( onClick = onCreatedListClick, imageVector = Icons.AutoMirrored.Outlined.ListAlt, contentDescription = stringResource(LocalizedString.activity_pub_created_list_title), ) } var showMorePopup by remember { mutableStateOf(false) } SimpleIconButton( onClick = { showMorePopup = true }, imageVector = Icons.Default.MoreVert, contentDescription = "More Options" ) var showBlockUserConfirmDialog by remember { mutableStateOf(false) } var showBlockDomainConfirmDialog by remember { mutableStateOf(false) } var showMuteDialog by remember { mutableStateOf(false) } PopupMenu( expanded = showMorePopup, onDismissRequest = { showMorePopup = false }, ) { DropDownOpenInBrowserItem { showMorePopup = false onOpenInBrowserClick() } DropDownCopyLinkItem { showMorePopup = false onCopyLinkClick() } DropDownOpenOriginalInstanceItem { showMorePopup = false onOpenOriginalInstanceClick() } val isAccountOwner = uiState.isAccountOwner if (isAccountOwner) { SelfAccountActions( onBookmarksClick = { showMorePopup = false onBookmarksClick() }, onFavouritesClick = { showMorePopup = false onFavouritesClick() }, onBlockedUserListClick = { showMorePopup = false onBlockedUserListClick() }, onMuteUserListClick = { showMorePopup = false onMuteUserListClick() }, onFollowedHashtagsListClick = { showMorePopup = false onFollowedHashtagsListClick() }, onFilterClick = { showMorePopup = false onFilterClick() }, onLogoutClick = { showMorePopup = false onLogoutClick() }, ) } val relationship = uiState.relationships if (!isAccountOwner && relationship != null) { OtherAccountActions( uiState = uiState, account = accountUiState, relationship = relationship, onNewNoteSet = onNewNoteSet, onDismissMorePopupRequest = { showMorePopup = false }, onShowBlockUserConfirmDialog = { showBlockUserConfirmDialog = true }, onShowBlockDomainConfirmDialog = { showBlockDomainConfirmDialog = true }, onUnblockDomainClick = onUnblockDomainClick, onUnmuteClick = onUnmuteUserClick, onShowMuteDialogClick = { showMuteDialog = true }, ) } } if (showBlockUserConfirmDialog) { AlertConfirmDialog( content = stringResource(LocalizedString.activity_pub_user_detail_dialog_content_block), onConfirm = { showBlockUserConfirmDialog = false onBlockClick() }, onDismissRequest = { showBlockUserConfirmDialog = false }, ) } if (showBlockDomainConfirmDialog) { AlertConfirmDialog( content = stringResource(LocalizedString.activity_pub_user_detail_dialog_content_block_domain), onConfirm = { showBlockDomainConfirmDialog = false onBlockDomainClick() }, onDismissRequest = { showBlockDomainConfirmDialog = false }, ) } if (showMuteDialog) { MuteUserBottomSheetDialog( account = accountUiState, onDismissRequest = { showMuteDialog = false }, onConfirmClick = { showMuteDialog = false onMuteUserClick() }, ) } } @Composable private fun EditPrivateNoteItem( note: String, onDismissRequest: () -> Unit, onNewNoteSet: (String) -> Unit, ) { var showEditDialog by remember { mutableStateOf(false) } ModalDropdownMenuItem( text = stringResource(LocalizedString.activity_pub_user_detail_menu_edit_private_note), imageVector = Icons.Default.Edit, onClick = { showEditDialog = true }, ) if (showEditDialog) { var inputtingNote by remember { mutableStateOf(note) } FreadDialog( onDismissRequest = { onDismissRequest() showEditDialog = false }, title = stringResource(LocalizedString.activity_pub_user_detail_menu_edit_private_note), content = { OutlinedTextField( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 16.dp), value = inputtingNote, onValueChange = { inputtingNote = it }, label = { Text( text = stringResource(LocalizedString.activity_pub_user_detail_menu_edit_private_note) ) }, placeholder = { Text( text = stringResource(LocalizedString.activity_pub_user_detail_menu_edit_private_note_dialog_hint) ) }, ) }, onNegativeClick = { onDismissRequest() showEditDialog = false }, onPositiveClick = { onDismissRequest() showEditDialog = false onNewNoteSet(inputtingNote) }, ) } } @Composable private fun SelfAccountActions( onBookmarksClick: () -> Unit, onFavouritesClick: () -> Unit, onBlockedUserListClick: () -> Unit, onMuteUserListClick: () -> Unit, onFollowedHashtagsListClick: () -> Unit, onFilterClick: () -> Unit, onLogoutClick: () -> Unit, ) { ModalDropdownMenuItem( text = stringResource(LocalizedString.activity_pub_favourites_list_title), onClick = onFavouritesClick, imageVector = Icons.Default.Favorite, ) ModalDropdownMenuItem( text = stringResource(LocalizedString.activity_pub_bookmarks_list_title), onClick = onBookmarksClick, imageVector = Icons.Default.Bookmarks, ) ModalDropdownMenuItem( text = stringResource(LocalizedString.activity_pub_followed_tags_screen_title), onClick = onFollowedHashtagsListClick, imageVector = Icons.Default.Tag, ) ModalDropdownMenuItem( text = stringResource(LocalizedString.activity_pub_user_menu_muted_user_list), imageVector = Icons.AutoMirrored.Filled.VolumeOff, onClick = onMuteUserListClick, ) ModalDropdownMenuItem( text = stringResource(LocalizedString.activity_pub_user_menu_blocked_user_list), imageVector = Icons.Default.Block, onClick = onBlockedUserListClick, ) ModalDropdownMenuItem( text = stringResource(LocalizedString.activity_pub_filters_list_page_title), imageVector = Icons.Default.FilterAlt, onClick = onFilterClick, ) var showLogoutDialog by remember { mutableStateOf(false) } ModalDropdownMenuItem( text = stringResource(LocalizedString.statusUiLogout), imageVector = Icons.AutoMirrored.Filled.Logout, colors = MenuDefaults.itemColors( textColor = MaterialTheme.colorScheme.error, leadingIconColor = MaterialTheme.colorScheme.error, ), onClick = { showLogoutDialog = true }, ) if (showLogoutDialog) { FreadDialog( onDismissRequest = { showLogoutDialog = false }, contentText = stringResource(LocalizedString.statusUiLogoutDialogContent), onPositiveClick = { showLogoutDialog = false onLogoutClick() }, onNegativeClick = { showLogoutDialog = false }, ) } } @Composable private fun OtherAccountActions( uiState: UserDetailUiState, account: UserDetailAccountUiState, relationship: Relationships, onNewNoteSet: (String) -> Unit, onUnmuteClick: () -> Unit, onDismissMorePopupRequest: () -> Unit, onUnblockDomainClick: () -> Unit, onShowBlockUserConfirmDialog: () -> Unit, onShowBlockDomainConfirmDialog: () -> Unit, onShowMuteDialogClick: () -> Unit, ) { EditPrivateNoteItem( note = uiState.personalNote.orEmpty(), onDismissRequest = onDismissMorePopupRequest, onNewNoteSet = onNewNoteSet, ) val fixedName = account.account.displayName.take(10) val muteOrUnmuteText = if (relationship.muting) { stringResource(LocalizedString.activity_pub_user_detail_menu_unmute_user, fixedName) } else { stringResource(LocalizedString.activity_pub_user_detail_menu_mute_user, fixedName) } ModalDropdownMenuItem( text = muteOrUnmuteText, imageVector = Icons.AutoMirrored.Filled.VolumeOff, onClick = { onDismissMorePopupRequest() if (relationship.muting) { onUnmuteClick() } else { onShowMuteDialogClick() } } ) if (!relationship.blocking) { ModalDropdownMenuItem( text = stringResource(LocalizedString.activity_pub_user_detail_menu_block, fixedName), imageVector = Icons.Default.Block, onClick = { onDismissMorePopupRequest() onShowBlockUserConfirmDialog() }, ) } val domainBlocked = uiState.domainBlocked val host = uiState.userInsight!!.baseUrl.host if (domainBlocked != null) { val blockDomainLabel = if (domainBlocked) { stringResource(LocalizedString.activity_pub_user_detail_menu_unblock_domain, host) } else { stringResource(LocalizedString.activity_pub_user_detail_menu_block_domain, host) } ModalDropdownMenuItem( text = blockDomainLabel, imageVector = Icons.Default.Block, onClick = { onDismissMorePopupRequest() if (domainBlocked) { onUnblockDomainClick() } else { onShowBlockDomainConfirmDialog() } } ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun MuteUserBottomSheetDialog( account: UserDetailAccountUiState, onDismissRequest: () -> Unit, onConfirmClick: () -> Unit, ) { val coroutineScope = rememberCoroutineScope() val state = rememberTransientModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( sheetState = state, onDismissRequest = onDismissRequest, ) { Column( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, end = 16.dp, bottom = 8.dp), ) { Text( modifier = Modifier.align(Alignment.CenterHorizontally), text = stringResource(LocalizedString.activity_pub_mute_user_bottom_sheet_title), style = MaterialTheme.typography.titleLarge, ) FreadRichText( modifier = Modifier .padding(top = 16.dp) .align(Alignment.CenterHorizontally), maxLines = 1, overflow = TextOverflow.Ellipsis, richText = account.userName, fontSize = 16.sp, ) Text( modifier = Modifier .padding(top = 6.dp) .align(Alignment.CenterHorizontally), text = account.account.prettyAcct, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.height(8.dp)) MuteUserRoleItem( icon = Icons.Default.Campaign, role = stringResource(LocalizedString.activity_pub_mute_user_bottom_sheet_role1) ) MuteUserRoleItem( icon = Icons.Default.VisibilityOff, role = stringResource(LocalizedString.activity_pub_mute_user_bottom_sheet_role2) ) MuteUserRoleItem( icon = Icons.Default.AlternateEmail, role = stringResource(LocalizedString.activity_pub_mute_user_bottom_sheet_role3) ) MuteUserRoleItem( icon = vectorResource(com.zhangke.fread.statusui.Res.drawable.ic_status_forward), role = stringResource(LocalizedString.activity_pub_mute_user_bottom_sheet_role4) ) Button( modifier = Modifier .padding(top = 16.dp) .fillMaxWidth() .height(40.dp), onClick = onConfirmClick, ) { Text(text = stringResource(LocalizedString.activity_pub_mute_user_bottom_sheet_btn_mute)) } TextButton( modifier = Modifier .align(Alignment.CenterHorizontally) .padding(top = 8.dp), onClick = { coroutineScope.launch { state.hide() onDismissRequest() } }, ) { Text(text = stringResource(LocalizedString.cancel)) } } } } @Composable private fun MuteUserRoleItem( icon: ImageVector, role: String, ) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = icon, contentDescription = null, ) Spacer(modifier = Modifier.width(16.dp)) Text( modifier = Modifier.weight(1F), text = role, textAlign = TextAlign.Start, ) } } @Composable private fun UserAboutCard( account: ActivityPubAccountEntity, locator: PlatformLocator, emojis: List, ) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme .surfaceContainerHighest .copy(alpha = 0.3F), ), ) { SelectionContainer { Column( modifier = Modifier.fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) { FieldLine( locator = locator, key = stringResource(LocalizedString.activity_pub_user_detail_join_date), value = DateParser.parseOrCurrent(account.createdAt).formatDate(), emojis = emojis, ) for (field in account.fields) { FieldLine( locator = locator, key = field.name, value = field.value, emojis = emojis, ) } } } } } @Composable private fun FieldLine( locator: PlatformLocator, key: String, value: String, emojis: List, ) { val browserLauncher = LocalActivityBrowserLauncher.current val coroutineScope = rememberCoroutineScope() Row( modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = Modifier.weight(2F), text = key, maxLines = 1, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.labelMedium, ) Spacer(modifier = Modifier.width(8.dp)) Box( modifier = Modifier.weight(4F), contentAlignment = Alignment.CenterEnd, ) { FreadRichText( modifier = Modifier, content = value, emojis = emojis, textAlign = TextAlign.End, maxLines = 3, fontSize = 14.sp, onUrlClick = { browserLauncher.launchWebTabInApp(coroutineScope, it, locator) }, ) } } } private val ActivityPubAccountEntity.prettyAcct: String get() { val acct = this.acct return if (acct.isNotEmpty() && !acct.contains('@')) { "@$acct" } else { acct } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/UserDetailUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.fread.activitypub.app.internal.model.UserUriInsights import com.zhangke.fread.status.model.Emoji import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.Relationships import com.zhangke.fread.status.richtext.RichText data class UserDetailUiState( val locator: PlatformLocator, val loading: Boolean, val userInsight: UserUriInsights?, val accountUiState: UserDetailAccountUiState?, val personalNote: String?, val relationships: Relationships?, val domainBlocked: Boolean?, val isAccountOwner: Boolean, ) data class UserDetailAccountUiState( val account: ActivityPubAccountEntity, val userName: RichText, val description: RichText, val emojis: List, ) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/UserDetailViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user import com.zhangke.activitypub.api.AccountsRepo import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.activitypub.entities.ActivityPubRelationshipEntity import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.composable.textOf import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.lifecycle.SubViewModel import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.activitypub.app.ActivityPubAccountManager import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubAccountEntityAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubCustomEmojiEntityAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.model.UserUriInsights import com.zhangke.fread.activitypub.app.internal.uri.UserUriTransformer import com.zhangke.fread.activitypub.app.internal.usecase.ActivityPubAccountLogoutUseCase import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.richtext.buildRichText import com.zhangke.fread.status.uri.FormalUri import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update class UserDetailViewModel( private val accountManager: ActivityPubAccountManager, private val accountEntityAdapter: ActivityPubAccountEntityAdapter, private val userUriTransformer: UserUriTransformer, private val clientManager: ActivityPubClientManager, private val emojiEntityAdapter: ActivityPubCustomEmojiEntityAdapter, private val accountLogout: ActivityPubAccountLogoutUseCase, val locator: PlatformLocator, val userUri: FormalUri?, val webFinger: WebFinger?, val userId: String?, ) : SubViewModel() { private val _uiState = MutableStateFlow( UserDetailUiState( locator = locator, loading = false, userInsight = null, accountUiState = null, domainBlocked = false, isAccountOwner = false, relationships = null, personalNote = null, ) ) val uiState = _uiState.asStateFlow() private val _messageFlow = MutableSharedFlow() val messageFlow = _messageFlow.asSharedFlow() private val _finishPageFlow = MutableSharedFlow() val finishPageFlow = _finishPageFlow.asSharedFlow() init { launchInViewModel { val webFinger = userUri?.let(userUriTransformer::parse)?.webFinger ?: webFinger if (webFinger == null) { _messageFlow.emit(textOf("Invalid user.")) return@launchInViewModel } _uiState.update { it.copy(loading = true) } val accountRepo = clientManager.getClient(locator).accountRepo val accountResult = loadAccountInfo(accountRepo, webFinger) if (accountResult.isFailure) { _uiState.update { it.copy(loading = false) } _messageFlow.emit(textOf("Failed to lookup ${webFinger}, ${accountResult.exceptionOrNull()!!.message}")) return@launchInViewModel } val account = accountResult.getOrThrow()!! val userInsight = accountEntityAdapter.toUri(account).let(userUriTransformer::parse)!! val isAccountOwner = accountManager.getAllLoggedAccount() .any { loggedAccount -> loggedAccount.uri == userInsight.uri } _uiState.value = _uiState.value.copy( userInsight = userInsight, isAccountOwner = isAccountOwner, accountUiState = account.toAccountUiState(), loading = false, ) loadRelationship(accountRepo, account.id) loadDomainBlockState(accountRepo, userInsight) } } private suspend fun loadAccountInfo( accountRepo: AccountsRepo, webFinger: WebFinger, ): Result { if (!userId.isNullOrEmpty()) { return accountRepo.getAccount(userId) } val resultOfWebFinger = accountRepo.lookup(webFinger.toString()) if (resultOfWebFinger.isSuccess) { return resultOfWebFinger } return accountRepo.lookup("@${webFinger.name}") } private suspend fun loadRelationship( accountRepo: AccountsRepo, accountId: String, ) { val relationshipEntityResult = accountRepo.getRelationships(listOf(accountId)) if (relationshipEntityResult.isFailure) { return } val relationshipEntity = relationshipEntityResult.getOrThrow().firstOrNull() ?: return _uiState.update { state -> state.copy( personalNote = relationshipEntity.note, relationships = accountEntityAdapter.convertRelationship(relationshipEntity), ) } } private suspend fun loadDomainBlockState( accountRepo: AccountsRepo, userUriInsights: UserUriInsights, ) { val blockedDomainList = accountRepo.getDomainBlocks().getOrNull() ?: return val domainBlocked = blockedDomainList.firstOrNull { it == userUriInsights.baseUrl.host } _uiState.value = _uiState.value.copy( domainBlocked = domainBlocked != null ) } fun onFollowClick() { performRelationshipAction { accountsRepo, accountId -> accountsRepo.follow(accountId) } } fun onUnfollowClick() { performRelationshipAction { accountsRepo, accountId -> accountsRepo.unfollow(accountId) } } fun onCancelFollowRequestClick() { performRelationshipAction { accountsRepo, accountId -> accountsRepo.unfollow(accountId) } } fun onBlockClick() { performRelationshipAction { accountsRepo, accountId -> accountsRepo.block(accountId) } } fun onUnblockClick() { performRelationshipAction { accountsRepo, accountId -> accountsRepo.unblock(accountId) } } fun onBlockDomainClick() { val userUriInsights = _uiState.value.userInsight ?: return launchInViewModel { val accountRepo = clientManager.getClient(locator).accountRepo accountRepo.blockDomain(userUriInsights.baseUrl.host) .onFailure { e -> e.message?.let { _messageFlow.emit(textOf(it)) } }.onSuccess { _uiState.update { it.copy(domainBlocked = true) } } } } fun onUnblockDomainClick() { val userUriInsights = _uiState.value.userInsight ?: return launchInViewModel { val accountRepo = clientManager.getClient(locator).accountRepo accountRepo.unblockDomain(userUriInsights.baseUrl.host) .onFailure { e -> e.message?.let { _messageFlow.emit(textOf(it)) } }.onSuccess { _uiState.update { it.copy(domainBlocked = false) } } } } fun onNewNoteSet(newNote: String) { val privateNote = uiState.value.personalNote if (newNote == privateNote) return val accountId = uiState.value.accountUiState?.account?.id ?: return launchInViewModel { val accountRepo = clientManager.getClient(locator).accountRepo accountRepo.updateNote(accountId, newNote) .onSuccess { relationship -> _uiState.update { it.copy( personalNote = relationship.note, relationships = accountEntityAdapter.convertRelationship(relationship), ) } }.onFailure { _messageFlow.emitTextMessageFromThrowable(it) } } } fun onMuteUserClick() { muteOrUnmute(true) } fun onUnmuteUserClick() { muteOrUnmute(false) } fun onLogoutClick() { val account = uiState.value.accountUiState?.account ?: return val uriInsights = uiState.value.userInsight ?: return launchInViewModel { accountLogout( baseUrl = locator.baseUrl, accountUri = uriInsights.uri, userId = account.id, ) _finishPageFlow.emit(Unit) } } private fun muteOrUnmute(mute: Boolean) { val accountId = uiState.value.accountUiState?.account?.id ?: return launchInViewModel { val accountRepo = clientManager.getClient(locator).accountRepo if (mute) { accountRepo.mute(accountId) } else { accountRepo.unmute(accountId) }.map { accountEntityAdapter.convertRelationship(it) } .onSuccess { relationship -> _uiState.update { it.copy(relationships = relationship) } }.onFailure { _messageFlow.emitTextMessageFromThrowable(it) } } } private fun performRelationshipAction( action: suspend (accountsRepo: AccountsRepo, accountId: String) -> Result, ) { val accountId = _uiState.value.accountUiState?.account?.id ?: return launchInViewModel { val accountRepo = clientManager.getClient(locator).accountRepo action(accountRepo, accountId) .onFailure { e -> e.message?.let { _messageFlow.emit(textOf(it)) } }.onSuccess { relationship -> _uiState.update { it.copy( relationships = accountEntityAdapter.convertRelationship(relationship), ) } } } } private fun ActivityPubAccountEntity.toAccountUiState(): UserDetailAccountUiState { val customEmojis = emojis.map(emojiEntityAdapter::toEmoji) return UserDetailAccountUiState( account = this, userName = buildRichText( document = getFixedName(), emojis = customEmojis, ), description = buildRichText( document = note, emojis = customEmojis, ), emojis = customEmojis, ) } private fun ActivityPubAccountEntity.getFixedName(): String { if (displayName.isNotEmpty()) return displayName if (username.isNotEmpty()) return username return "" } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/list/UserListScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.list import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.StyledTextButton import com.zhangke.framework.composable.TextButtonStyle import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.loadable.lazycolumn.LoadableLazyColumn import com.zhangke.framework.loadable.lazycolumn.rememberLoadableLazyColumnState import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.activitypub.app.internal.screen.user.UserDetailScreenKey import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.ui.user.CommonUserPlaceHolder import com.zhangke.fread.status.ui.user.CommonUserUi import com.zhangke.fread.status.uri.FormalUri import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable data class UserListScreenKey( val locator: PlatformLocator, val type: UserListType, val statusId: String? = null, val userUri: FormalUri? = null, val userId: String? = null, ) : NavKey @Composable fun UserListScreen(viewModel: UserListViewModel) { val backStack = LocalNavBackStack.currentOrThrow val uiState by viewModel.uiState.collectAsState() val snackBarHostState = rememberSnackbarHostState() UserListContent( uiState = uiState, snackBarHostState = snackBarHostState, onRefresh = viewModel::onRefresh, onLoadMore = viewModel::onLoadMore, onUnblockClick = viewModel::onUnblockClick, onUnmuteClick = viewModel::onUnmuteClick, onBackClick = backStack::removeLastOrNull, onFollowClick = viewModel::onFollowClick, ) ConsumeSnackbarFlow(snackBarHostState, viewModel.snackMessageFlow) } @Composable private fun UserListContent( uiState: UserListUiState, snackBarHostState: SnackbarHostState, onBackClick: () -> Unit, onUnblockClick: (BlogAuthor) -> Unit, onUnmuteClick: (BlogAuthor) -> Unit, onRefresh: () -> Unit, onLoadMore: () -> Unit, onFollowClick: (BlogAuthorUiState) -> Unit, ) { val backStack = LocalNavBackStack.currentOrThrow Scaffold( topBar = { Toolbar( title = uiState.type.title, onBackClick = onBackClick, ) }, snackbarHost = { SnackbarHost(snackBarHostState) }, ) { innerPadding -> Box( modifier = Modifier .fillMaxSize() .padding(innerPadding) ) { if (uiState.userList.isNotEmpty()) { val loadableState = rememberLoadableLazyColumnState( onRefresh = onRefresh, onLoadMore = onLoadMore, ) LoadableLazyColumn( modifier = Modifier.fillMaxSize(), state = loadableState, refreshing = uiState.loading, loadState = uiState.loadMoreState, ) { itemsIndexed(uiState.userList) { index, item -> CommonUserUi( modifier = Modifier.clickable { backStack.add( UserDetailScreenKey( locator = uiState.locator, webFinger = item.author.webFinger, userId = item.author.userId, ) ) }, user = item.author, showDivider = index < uiState.userList.lastIndex, actionButton = { StatusAction( authorUiState = item, type = uiState.type, onUnblockClick = onUnblockClick, onUnmuteClick = onUnmuteClick, onFollowClick = onFollowClick, ) }, ) } } } else if (uiState.loading) { LazyColumn( modifier = Modifier.fillMaxSize(), ) { items(30) { CommonUserPlaceHolder() } } } else { Box(modifier = Modifier.fillMaxSize()) { Text( modifier = Modifier.align(Alignment.Center), text = stringResource(LocalizedString.activity_pub_user_list_empty), ) } } } } } @Composable private fun RowScope.StatusAction( authorUiState: BlogAuthorUiState, type: UserListType, onUnblockClick: (BlogAuthor) -> Unit, onUnmuteClick: (BlogAuthor) -> Unit, onFollowClick: (BlogAuthorUiState) -> Unit, ) { val author = authorUiState.author when (type) { UserListType.BLOCKED -> { Spacer(modifier = Modifier.width(6.dp)) StyledTextButton( modifier = Modifier.align(Alignment.CenterVertically), text = stringResource(LocalizedString.sharedUserListActionBlocked), style = TextButtonStyle.STANDARD, onClick = { onUnblockClick(author) }, ) } UserListType.MUTED -> { Spacer(modifier = Modifier.width(6.dp)) StyledTextButton( modifier = Modifier.align(Alignment.CenterVertically), text = stringResource(LocalizedString.sharedUserListActionMuted), style = TextButtonStyle.STANDARD, onClick = { onUnmuteClick(author) }, ) } UserListType.REBLOGS, UserListType.FOLLOWERS, UserListType.FAVOURITES -> { if (authorUiState.following == false) { Spacer(modifier = Modifier.width(6.dp)) StyledTextButton( modifier = Modifier.align(Alignment.CenterVertically), text = stringResource(LocalizedString.statusUiFollow), style = TextButtonStyle.STANDARD, onClick = { onFollowClick(authorUiState) }, ) } } else -> {} } } private val UserListType.title: String @Composable get() = when (this) { UserListType.FAVOURITES -> stringResource(LocalizedString.sharedUserListTitleLikes) UserListType.REBLOGS -> stringResource(LocalizedString.sharedUserListTitleReblog) UserListType.MUTED -> stringResource(LocalizedString.sharedUserListTitleMutes) UserListType.BLOCKED -> stringResource(LocalizedString.sharedUserListTitleBlocks) UserListType.FOLLOWERS -> stringResource(LocalizedString.sharedUserListTitleFollowers) UserListType.FOLLOWING -> stringResource(LocalizedString.sharedUserListTitleFollowing) } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/list/UserListType.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.list import com.zhangke.framework.utils.PlatformSerializable import kotlinx.serialization.Serializable @Serializable enum class UserListType: PlatformSerializable { FAVOURITES, REBLOGS, BLOCKED, MUTED, FOLLOWERS, FOLLOWING, } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/list/UserListUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.list import com.zhangke.framework.utils.LoadState import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.PlatformLocator data class UserListUiState( val type: UserListType, val locator: PlatformLocator, val loading: Boolean, val userList: List, val loadMoreState: LoadState, ) data class BlogAuthorUiState( val author: BlogAuthor, val following: Boolean? = null, ) ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/list/UserListViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.list import androidx.lifecycle.ViewModel import com.zhangke.activitypub.api.PagingResult import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.composable.textOf import com.zhangke.framework.composable.toTextStringOrNull import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.utils.LoadState import com.zhangke.framework.utils.exceptionOrThrow import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubAccountEntityAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.repo.WebFingerBaseUrlToUserIdRepo import com.zhangke.fread.activitypub.app.internal.uri.UserUriTransformer import com.zhangke.fread.common.status.StatusConfigurationDefault import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.uri.FormalUri import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.update class UserListViewModel ( private val clientManager: ActivityPubClientManager, private val userUriTransformer: UserUriTransformer, private val webFingerBaseUrlToUserIdRepo: WebFingerBaseUrlToUserIdRepo, private val accountEntityAdapter: ActivityPubAccountEntityAdapter, private val locator: PlatformLocator, private val type: UserListType, private val statusId: String?, private val userUri: FormalUri?, private val userId: String?, ) : ViewModel() { private val _uiState = MutableStateFlow( UserListUiState( type = type, locator = locator, loading = false, userList = emptyList(), loadMoreState = LoadState.Idle, ) ) val uiState: StateFlow = _uiState private val mutableSnackMessageFlow = MutableSharedFlow() val snackMessageFlow = mutableSnackMessageFlow.asSharedFlow() private var refreshJob: Job? = null private var loadMoreJob: Job? = null private var nextMaxId: String? = null private var cachedUserId: String? = userId init { loadFirstPageUsers() } fun onRefresh() { loadFirstPageUsers() } fun onLoadMore() { loadNextPageUsers() } fun onFollowClick(authorUiState: BlogAuthorUiState) { } private fun loadFirstPageUsers() { if (refreshJob?.isActive == true) return loadMoreJob?.cancel() _uiState.update { it.copy(loading = true) } refreshJob = launchInViewModel { fetchUserListFromServer() .onSuccess { _uiState.update { state -> state.copy( loading = false, userList = it, ) } }.onFailure { t -> _uiState.update { it.copy(loading = false) } mutableSnackMessageFlow.emitTextMessageFromThrowable(t) } } } private fun loadNextPageUsers() { if (refreshJob?.isActive == true) return if (loadMoreJob?.isActive == true) return if (nextMaxId.isNullOrEmpty()) return loadMoreJob = launchInViewModel { _uiState.update { it.copy(loadMoreState = LoadState.Loading) } fetchUserListFromServer(nextMaxId) .onSuccess { _uiState.update { state -> state.copy( loadMoreState = LoadState.Idle, userList = state.userList + it, ) } }.onFailure { t -> _uiState.update { it.copy(loadMoreState = LoadState.Failed(t.toTextStringOrNull())) } } } } private suspend fun fetchUserListFromServer(maxId: String? = null): Result> { val client = clientManager.getClient(locator) val pagingResult = when (type) { UserListType.REBLOGS -> client.statusRepo.getReblogBy( statusId = statusId!!, maxId = maxId, limit = StatusConfigurationDefault.config.loadFromServerLimit, ) UserListType.FAVOURITES -> client.statusRepo.getFavouritesBy( statusId = statusId!!, maxId = maxId, limit = StatusConfigurationDefault.config.loadFromServerLimit, ) UserListType.BLOCKED -> client.accountRepo.getBlockedUserList( maxId = nextMaxId, limit = StatusConfigurationDefault.config.loadFromServerLimit, ) UserListType.MUTED -> client.accountRepo.getMutedUserList( maxId = nextMaxId, limit = StatusConfigurationDefault.config.loadFromServerLimit, ) UserListType.FOLLOWING -> getFollowUserList(maxId) UserListType.FOLLOWERS -> getFollowUserList(maxId) } return pagingResult.map { nextMaxId = it.pagingInfo.nextMaxId it.data.toAuthors() } } private suspend fun getFollowUserList(maxId: String?): Result>> { val userIdResult = getPageTargetUserId() if (userIdResult.isFailure) return Result.failure(userIdResult.exceptionOrThrow()) val userId = userIdResult.getOrThrow() val accountRepo = clientManager.getClient(locator).accountRepo return if (type == UserListType.FOLLOWERS) { accountRepo.getFollowers( id = userId, limit = StatusConfigurationDefault.config.loadFromServerLimit, maxId = maxId, ) } else { accountRepo.getFollowing( id = userId, limit = StatusConfigurationDefault.config.loadFromServerLimit, maxId = maxId, ) } } fun onUnblockClick(author: BlogAuthor) { launchInViewModel { val id = author.userId ?: getUserIdByUri(author.uri) ?: return@launchInViewModel clientManager.getClient(locator) .accountRepo .unblock(id) .onFailure { mutableSnackMessageFlow.emitTextMessageFromThrowable(it) }.onSuccess { if (type == UserListType.BLOCKED) { _uiState.update { state -> state.copy( userList = state.userList.filterNot { it.author.uri == author.uri } ) } } } } } fun onUnmuteClick(author: BlogAuthor) { launchInViewModel { val accountRepo = clientManager.getClient(locator).accountRepo val authorId = author.userId ?: getUserIdByUri(author.uri) ?: return@launchInViewModel accountRepo.unmute(authorId) .onSuccess { if (type == UserListType.MUTED) { _uiState.update { state -> state.copy( userList = state.userList.filterNot { it.author.uri == author.uri } ) } } }.onFailure { mutableSnackMessageFlow.emitTextMessageFromThrowable(it) } } } private suspend fun getPageTargetUserId(): Result { if (!cachedUserId.isNullOrEmpty()) return Result.success(cachedUserId!!) if (userUri == null) { return Result.failure(IllegalStateException("User uri is null!")) } val userId = cachedUserId ?: getUserIdByUri(userUri)?.also { this.cachedUserId = it } if (userId == null) { return Result.failure(IllegalStateException("Invalid user uri: $userUri")) } return Result.success(userId) } private suspend fun getUserIdByUri(uri: FormalUri): String? { val userInsight = userUriTransformer.parse(uri) if (userInsight == null) { mutableSnackMessageFlow.emit(textOf("Invalid user uri: $uri")) return null } val userIdResult = webFingerBaseUrlToUserIdRepo.getUserId(userInsight.webFinger, locator) if (userIdResult.isFailure) { mutableSnackMessageFlow.emitTextMessageFromThrowable(userIdResult.exceptionOrThrow()) return null } return userIdResult.getOrNull() } private fun List.toAuthors(): List { return this.map { it.toAuthor() } } private fun ActivityPubAccountEntity.toAuthor(): BlogAuthorUiState { return BlogAuthorUiState( author = accountEntityAdapter.toAuthor(this), following = null, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/search/SearchUserScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.search import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.currentOrThrow import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.framework.nav.ScreenEventFlow import com.zhangke.framework.utils.transparentIndicatorColors import com.zhangke.fread.activitypub.app.internal.screen.list.AccountItem import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable data class SearchUserScreenNavKey( val locator: PlatformLocator, val onlyFollowing: Boolean, ) : NavKey { companion object { val accountSelectedFlow = ScreenEventFlow() } } @Composable fun SearchUserScreen(viewModel: SearchUserViewModel) { val backStack = LocalNavBackStack.currentOrThrow val snackbarHostState = rememberSnackbarHostState() val uiState by viewModel.uiState.collectAsState() val coroutineScope = rememberCoroutineScope() SearchUserContent( uiState = uiState, snackbarHostState = snackbarHostState, onAccountClicked = { coroutineScope.launch { SearchUserScreenNavKey.accountSelectedFlow.emit(it) backStack.removeLastOrNull() } }, onQueryChange = viewModel::onQueryChange, onSearchClick = viewModel::onSearchClick, onBackClick = backStack::removeLastOrNull, ) ConsumeSnackbarFlow(snackbarHostState, viewModel.snackBarMessage) } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SearchUserContent( uiState: SearchUserUiState, snackbarHostState: SnackbarHostState, onAccountClicked: (ActivityPubAccountEntity) -> Unit, onQueryChange: (String) -> Unit, onSearchClick: () -> Unit, onBackClick: () -> Unit, ) { Scaffold( topBar = { TopAppBar( title = { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus() } TextField( modifier = Modifier.fillMaxWidth() .focusRequester(focusRequester), value = uiState.query, onValueChange = { onQueryChange(it) }, placeholder = { Text( text = stringResource(LocalizedString.activity_pub_search_user_placeholder) ) }, keyboardActions = KeyboardActions( onSearch = { onSearchClick() } ), singleLine = true, maxLines = 1, keyboardOptions = KeyboardOptions.Default.copy( imeAction = ImeAction.Search ), colors = TextFieldDefaults.transparentIndicatorColors.copy( focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, ), textStyle = MaterialTheme.typography.titleMedium, ) }, navigationIcon = { Toolbar.BackButton(onBackClick = onBackClick) }, ) }, snackbarHost = { SnackbarHost(snackbarHostState) } ) { innerPadding -> Box( modifier = Modifier.fillMaxSize() .padding(innerPadding), ) { LazyColumn( modifier = Modifier.fillMaxSize(), ) { items(uiState.accounts) { AccountItem( modifier = Modifier.clickable { onAccountClicked(it) }, account = it, showRemoveIcon = false, ) } } if (uiState.searching) { CircularProgressIndicator( modifier = Modifier.align(Alignment.Center).size(64.dp) ) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/search/SearchUserUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.search import com.zhangke.activitypub.entities.ActivityPubAccountEntity data class SearchUserUiState( val query: String, val searching: Boolean, val accounts: List ) { companion object { fun default(): SearchUserUiState { return SearchUserUiState( query = "", searching = false, accounts = emptyList(), ) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/search/SearchUserViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.search import androidx.lifecycle.ViewModel import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.status.model.PlatformLocator import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update class SearchUserViewModel ( private val clientManager: ActivityPubClientManager, private val locator: PlatformLocator, private val onlyFollowing: Boolean, ) : ViewModel() { private val _uiState = MutableStateFlow(SearchUserUiState.default()) val uiState = _uiState.asStateFlow() private val _snackBarMessage = MutableSharedFlow() val snackBarMessage = _snackBarMessage.asSharedFlow() private var searchJob: Job? = null fun onQueryChange(query: String) { _uiState.update { it.copy(query = query) } } fun onSearchClick() { searchJob?.cancel() val query = _uiState.value.query _uiState.update { it.copy(searching = true) } searchJob = launchInViewModel { clientManager.getClient(locator) .accountRepo .search( query = query, resolve = false, following = onlyFollowing, ).onSuccess { accounts -> _uiState.update { it.copy( searching = false, accounts = accounts, ) } }.onFailure { _uiState.update { it.copy(searching = false) } _snackBarMessage.emitTextMessageFromThrowable(it) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/status/StatusListContainerViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.status import com.zhangke.framework.lifecycle.ContainerViewModel import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.PlatformLocator class StatusListContainerViewModel ( private val clientManager: ActivityPubClientManager, private val statusAdapter: ActivityPubStatusAdapter, private val statusProvider: StatusProvider, private val statusUpdater: StatusUpdater, private val platformRepo: ActivityPubPlatformRepo, private val statusUiStateAdapter: StatusUiStateAdapter, private val refactorToNewStatus: RefactorToNewStatusUseCase, private val loggedAccountProvider: LoggedAccountProvider, ) : ContainerViewModel() { override fun createSubViewModel(params: ViewModelParams): StatusListViewModel { return StatusListViewModel( clientManager = clientManager, statusAdapter = statusAdapter, statusProvider = statusProvider, statusUpdater = statusUpdater, platformRepo = platformRepo, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, loggedAccountProvider = loggedAccountProvider, locator = params.locator, type = params.type, ) } fun getViewModel( locator: PlatformLocator, type: StatusListType, ): StatusListViewModel { return obtainSubViewModel(ViewModelParams(locator, type)) } class ViewModelParams( val locator: PlatformLocator, val type: StatusListType, ) : SubViewModelParams() { override val key: String = locator.toString() + type } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/status/StatusListScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.status import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.LocalSnackbarHostState import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.status.model.PlatformLocator import kotlinx.serialization.Serializable @Serializable data class StatusListScreenKey( val locator: PlatformLocator, val type: StatusListType, ) : NavKey @Composable fun StatusListScreen(locator: PlatformLocator, type: StatusListType) { val tab = remember(locator, type) { StatusListTabStatusListScreen( locator = locator, type = type, contentCanScrollBackward = null, ) } val snackBarHostState = rememberSnackbarHostState() val backStack = LocalNavBackStack.currentOrThrow Scaffold( topBar = { Toolbar( title = tab.options?.title.orEmpty(), onBackClick = backStack::removeLastOrNull, ) }, snackbarHost = { SnackbarHost(hostState = snackBarHostState) }, ) { innerPadding -> Box( modifier = Modifier .padding(innerPadding) .fillMaxSize() ) { CompositionLocalProvider( LocalSnackbarHostState provides snackBarHostState ) { tab.Content() } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/status/StatusListTab.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.status import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.LocalSnackbarHostState import com.zhangke.framework.nav.BaseTab import com.zhangke.framework.nav.TabOptions import com.zhangke.fread.commonbiz.shared.composable.FeedsContent import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel class StatusListTabStatusListScreen( private val locator: PlatformLocator, private val type: StatusListType, private val contentCanScrollBackward: MutableState?, ) : BaseTab() { override val options: TabOptions? @Composable get() = TabOptions( title = when (type) { StatusListType.BOOKMARKS -> stringResource(LocalizedString.statusUiBookmarks) StatusListType.FAVOURITES -> stringResource(LocalizedString.statusUiLikes) }, ) @Composable override fun Content() { super.Content() val viewModel = koinViewModel().getViewModel(locator, type) val uiState by viewModel.uiState.collectAsState() val snackBarHostState = LocalSnackbarHostState.current Box(modifier = Modifier.fillMaxSize()) { FeedsContent( uiState = uiState, openScreenFlow = viewModel.openScreenFlow, newStatusNotifyFlow = viewModel.newStatusNotifyFlow, onRefresh = viewModel::onRefresh, onLoadMore = viewModel::onLoadMore, contentCanScrollBackward = contentCanScrollBackward, composedStatusInteraction = viewModel.composedStatusInteraction, observeScrollToTopEvent = true, onImmersiveEvent = {}, onScrollInProgress = {}, ) } ConsumeSnackbarFlow( hostState = snackBarHostState, messageTextFlow = viewModel.errorMessageFlow, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/status/StatusListType.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.status import com.zhangke.framework.utils.PlatformSerializable import kotlinx.serialization.Serializable @Serializable enum class StatusListType: PlatformSerializable { FAVOURITES, BOOKMARKS, } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/status/StatusListViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.status import com.zhangke.activitypub.api.AccountsRepo import com.zhangke.activitypub.api.PagingResult import com.zhangke.activitypub.entities.ActivityPubStatusEntity import com.zhangke.framework.lifecycle.SubViewModel import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.feeds.model.RefreshResult import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.feeds.FeedsViewModelController import com.zhangke.fread.commonbiz.shared.feeds.IFeedsViewModelController import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.richtext.preParseStatus class StatusListViewModel( private val clientManager: ActivityPubClientManager, private val statusAdapter: ActivityPubStatusAdapter, private val statusProvider: StatusProvider, statusUpdater: StatusUpdater, private val platformRepo: ActivityPubPlatformRepo, private val statusUiStateAdapter: StatusUiStateAdapter, private val refactorToNewStatus: RefactorToNewStatusUseCase, private val loggedAccountProvider: LoggedAccountProvider, private val locator: PlatformLocator, val type: StatusListType, ) : SubViewModel(), IFeedsViewModelController by FeedsViewModelController( statusProvider = statusProvider, statusUpdater = statusUpdater, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, ) { private var nextMaxId: String? = null init { initController( coroutineScope = viewModelScope, locatorResolver = { locator }, loadFirstPageLocalFeeds = { Result.success(emptyList()) }, loadNewFromServerFunction = ::loadNewDataFromServer, loadMoreFunction = { loadMoreDataFromServer() }, onStatusUpdate = {}, ) initFeeds(false) } private suspend fun loadNewDataFromServer(): Result { nextMaxId = null val accountRepo = clientManager.getClient(locator).accountRepo val platformResult = platformRepo.getPlatform(locator) if (platformResult.isFailure) { return Result.failure(platformResult.exceptionOrNull()!!) } val platform = platformResult.getOrThrow() val loggedAccount = locator.accountUri?.let { loggedAccountProvider.getAccount(it) } return fetchStatuses(accountRepo) .map { pagingResult -> nextMaxId = pagingResult.pagingInfo.nextMaxId RefreshResult( newStatus = pagingResult.data.map { it.toUiState(loggedAccount, platform) }, deletedStatus = emptyList(), ) } } private suspend fun loadMoreDataFromServer(): Result> { val nextMaxId = nextMaxId if (nextMaxId.isNullOrEmpty()) { return Result.success(emptyList()) } val accountRepo = clientManager.getClient(locator).accountRepo val platformResult = platformRepo.getPlatform(locator) if (platformResult.isFailure) { return Result.failure(platformResult.exceptionOrNull()!!) } val platform = platformResult.getOrThrow() val loggedAccount = locator.accountUri?.let { loggedAccountProvider.getAccount(it) } return fetchStatuses(accountRepo, nextMaxId) .map { pagingResult -> this@StatusListViewModel.nextMaxId = pagingResult.pagingInfo.nextMaxId pagingResult.data.map { it.toUiState(loggedAccount, platform) } } } private suspend fun fetchStatuses( accountRepo: AccountsRepo, maxId: String? = null, ): Result>> { return if (type == StatusListType.FAVOURITES) { accountRepo.getFavourites(maxId = maxId) } else { accountRepo.getBookmarks(maxId = maxId) } } private suspend fun ActivityPubStatusEntity.toUiState( loggedAccount: ActivityPubLoggedAccount?, platform: BlogPlatform, ): StatusUiState { val status = statusAdapter.toStatusUiState( entity = this, platform = platform, locator = locator, loggedAccount = loggedAccount, ) status.status.preParseStatus() return status } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/tags/TagListScreen.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.tags import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.items import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.navigation3.runtime.NavKey import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.composable.currentOrThrow import com.zhangke.framework.composable.rememberSnackbarHostState import com.zhangke.framework.loadable.lazycolumn.LoadableLazyColumn import com.zhangke.framework.loadable.lazycolumn.rememberLoadableLazyColumnState import com.zhangke.framework.nav.LocalNavBackStack import com.zhangke.fread.activitypub.app.internal.screen.hashtag.HashtagTimelineScreenKey import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.Hashtag import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.ui.hashtag.HashtagUi import com.zhangke.fread.status.ui.hashtag.HashtagUiPlaceholder import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource @Serializable data class TagListScreenKey( val locator: PlatformLocator, ) : NavKey @Composable fun TagListScreen(viewModel: TagListViewModel) { val backStack = LocalNavBackStack.currentOrThrow val uiState by viewModel.uiState.collectAsState() val snackbarHostState = rememberSnackbarHostState() TagListContent( uiState = uiState, snackbarHostState = snackbarHostState, onBackClick = backStack::removeLastOrNull, onRefresh = viewModel::onRefresh, onLoadMore = viewModel::onLoadMore, onTagClick = { hashtag -> backStack.add( HashtagTimelineScreenKey( locator = uiState.locator, hashtag = hashtag.name.removePrefix("#"), ) ) }, ) ConsumeSnackbarFlow(snackbarHostState, viewModel.snackBarMessageFlow) } @Composable private fun TagListContent( uiState: TagListUiState, snackbarHostState: SnackbarHostState, onBackClick: () -> Unit, onRefresh: () -> Unit, onLoadMore: () -> Unit, onTagClick: (Hashtag) -> Unit, ) { Scaffold( topBar = { Toolbar( title = stringResource(LocalizedString.activity_pub_followed_tags_screen_title), onBackClick = onBackClick, ) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { innerPadding -> val loadableState = rememberLoadableLazyColumnState( onRefresh = onRefresh, onLoadMore = onLoadMore, ) LoadableLazyColumn( modifier = Modifier .fillMaxSize() .padding(innerPadding), state = loadableState, refreshing = uiState.refreshing, loadState = uiState.loadState, ) { if (uiState.tags.isNotEmpty()) { items(uiState.tags) { tag -> HashtagUi( tag = tag, onClick = onTagClick, ) } } else if (uiState.refreshing) { items(20) { HashtagUiPlaceholder() } } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/tags/TagListUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.tags import com.zhangke.framework.utils.LoadState import com.zhangke.fread.status.model.Hashtag import com.zhangke.fread.status.model.PlatformLocator data class TagListUiState( val locator: PlatformLocator, val refreshing: Boolean, val tags: List, val loadState: LoadState, ) { companion object { fun default(locator: PlatformLocator) = TagListUiState( locator = locator, refreshing = false, tags = emptyList(), loadState = LoadState.Idle, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/tags/TagListViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.tags import androidx.lifecycle.ViewModel import com.zhangke.framework.composable.TextString import com.zhangke.framework.composable.emitTextMessageFromThrowable import com.zhangke.framework.composable.toTextStringOrNull import com.zhangke.framework.ktx.launchInViewModel import com.zhangke.framework.utils.LoadState import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubTagAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.status.model.Hashtag import com.zhangke.fread.status.model.PlatformLocator import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update class TagListViewModel ( private val clientManager: ActivityPubClientManager, private val activityPubTagAdapter: ActivityPubTagAdapter, private val locator: PlatformLocator, ) : ViewModel() { private val _uiState = MutableStateFlow(TagListUiState.default(locator)) val uiState: StateFlow = _uiState private val _snackBarMessageFlow = MutableSharedFlow() val snackBarMessageFlow: SharedFlow = _snackBarMessageFlow private var nextMaxId: String? = null private var initializingJob: Job? = null private var loadMoreJob: Job? = null init { initializingFollowedTags() } fun onRefresh() { initializingFollowedTags() } private fun initializingFollowedTags() { if (initializingJob?.isActive == true) return loadMoreJob?.cancel() launchInViewModel { nextMaxId = null _uiState.update { it.copy(refreshing = true) } fetchFollowedTags() .onFailure { t -> _uiState.update { it.copy(refreshing = false) } _snackBarMessageFlow.emitTextMessageFromThrowable(t) }.onSuccess { list -> _uiState.update { it.copy( refreshing = false, tags = list, ) } } } } fun onLoadMore() { if (initializingJob?.isActive == true) return if (loadMoreJob?.isActive == true) return val nextMaxId = nextMaxId ?: return if (nextMaxId.isEmpty()) return launchInViewModel { _uiState.update { it.copy(loadState = LoadState.Loading) } fetchFollowedTags(nextMaxId) .onFailure { t -> _uiState.update { it.copy(loadState = LoadState.Failed(t.toTextStringOrNull())) } }.onSuccess { list -> _uiState.update { it.copy( loadState = LoadState.Idle, tags = it.tags + list, ) } } } } private suspend fun fetchFollowedTags(maxId: String? = null): Result> { return clientManager.getClient(locator) .accountRepo .getFollowedTags(maxId = maxId) .map { pagingResult -> nextMaxId = pagingResult.pagingInfo.nextMaxId pagingResult.data.map { activityPubTagAdapter.adapt(it) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/timeline/UserTimelineContainerViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.timeline import com.zhangke.framework.lifecycle.ContainerViewModel import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.repo.WebFingerBaseUrlToUserIdRepo import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.PlatformLocator class UserTimelineContainerViewModel ( private val statusProvider: StatusProvider, private val webFingerBaseUrlToUserIdRepo: WebFingerBaseUrlToUserIdRepo, private val statusUpdater: StatusUpdater, private val statusUiStateAdapter: StatusUiStateAdapter, private val platformRepo: ActivityPubPlatformRepo, private val statusAdapter: ActivityPubStatusAdapter, private val clientManager: ActivityPubClientManager, private val refactorToNewStatus: RefactorToNewStatusUseCase, private val loggedAccountProvider: LoggedAccountProvider, ) : ContainerViewModel() { override fun createSubViewModel(params: Params): UserTimelineViewModel { return UserTimelineViewModel( statusProvider = statusProvider, statusUpdater = statusUpdater, webFingerBaseUrlToUserIdRepo = webFingerBaseUrlToUserIdRepo, statusUiStateAdapter = statusUiStateAdapter, platformRepo = platformRepo, statusEntityAdapter = statusAdapter, clientManager = clientManager, refactorToNewStatus = refactorToNewStatus, tabType = params.tabType, locator = params.locator, webFinger = params.webFinger, loggedAccountProvider = loggedAccountProvider, userId = params.userId, ) } fun getSubViewModel( tabType: UserTimelineTabType, locator: PlatformLocator, webFinger: WebFinger, userId: String?, ): UserTimelineViewModel { return obtainSubViewModel( Params(tabType, locator, webFinger, userId) ) } class Params( val tabType: UserTimelineTabType, val locator: PlatformLocator, val webFinger: WebFinger, val userId: String?, ) : SubViewModelParams() { override val key: String get() = tabType.toString() + locator.toString() + webFinger + userId } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/timeline/UserTimelineTab.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.timeline import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import com.zhangke.framework.composable.ConsumeSnackbarFlow import com.zhangke.framework.composable.LocalSnackbarHostState import com.zhangke.framework.nav.BaseTab import com.zhangke.framework.nav.TabOptions import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.commonbiz.shared.composable.FeedsContent import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.ui.common.LocalStatusSharedElementConfig import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel internal class UserTimelineTab( private val tabType: UserTimelineTabType, private val contentCanScrollBackward: MutableState, private val locator: PlatformLocator, private val userWebFinger: WebFinger, private val userId: String?, ) : BaseTab() { override val options: TabOptions @Composable get() { val title = when (tabType) { UserTimelineTabType.POSTS -> LocalizedString.activity_pub_user_detail_tab_post UserTimelineTabType.REPLIES -> LocalizedString.activity_pub_user_detail_tab_replies UserTimelineTabType.MEDIA -> LocalizedString.activity_pub_user_detail_tab_media } return TabOptions(title = stringResource(title)) } @Composable override fun Content() { super.Content() val viewModel = koinViewModel().getSubViewModel( tabType = tabType, locator = locator, webFinger = userWebFinger, userId = userId, ) val uiState by viewModel.uiState.collectAsState() val preSharedElementConfig = LocalStatusSharedElementConfig.current val sharedElementConfig = remember(preSharedElementConfig) { preSharedElementConfig.copy(label = "user-timeline") } CompositionLocalProvider( LocalStatusSharedElementConfig provides sharedElementConfig ) { FeedsContent( uiState = uiState, openScreenFlow = viewModel.openScreenFlow, newStatusNotifyFlow = viewModel.newStatusNotifyFlow, onRefresh = viewModel::onRefresh, onLoadMore = viewModel::onLoadMore, composedStatusInteraction = viewModel.composedStatusInteraction, observeScrollToTopEvent = true, contentCanScrollBackward = contentCanScrollBackward, nestedScrollConnection = null, onImmersiveEvent = {}, onScrollInProgress = {}, ) } val snackbarHostState = LocalSnackbarHostState.current ConsumeSnackbarFlow(snackbarHostState, viewModel.errorMessageFlow) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/timeline/UserTimelineTabType.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.timeline enum class UserTimelineTabType { POSTS, REPLIES, MEDIA, } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/timeline/UserTimelineUiState.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.timeline import com.zhangke.framework.composable.TextString import com.zhangke.framework.utils.LoadState import com.zhangke.fread.status.model.StatusUiState data class UserTimelineUiState( val feeds: List, val showPagingLoadingPlaceholder: Boolean, val pageErrorContent: TextString?, val refreshing: Boolean, val loadMoreState: LoadState, ) { companion object { fun default() = UserTimelineUiState( feeds = emptyList(), showPagingLoadingPlaceholder = false, pageErrorContent = null, refreshing = false, loadMoreState = LoadState.Idle, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/screen/user/timeline/UserTimelineViewModel.kt ================================================ package com.zhangke.fread.activitypub.app.internal.screen.user.timeline import com.zhangke.activitypub.entities.ActivityPubStatusEntity import com.zhangke.framework.lifecycle.SubViewModel import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.repo.WebFingerBaseUrlToUserIdRepo import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import com.zhangke.fread.common.adapter.StatusUiStateAdapter import com.zhangke.fread.common.feeds.model.RefreshResult import com.zhangke.fread.common.status.StatusUpdater import com.zhangke.fread.commonbiz.shared.feeds.FeedsViewModelController import com.zhangke.fread.commonbiz.shared.feeds.IFeedsViewModelController import com.zhangke.fread.commonbiz.shared.usecase.RefactorToNewStatusUseCase import com.zhangke.fread.status.StatusProvider import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.richtext.preParseStatus class UserTimelineViewModel( private val webFingerBaseUrlToUserIdRepo: WebFingerBaseUrlToUserIdRepo, private val statusProvider: StatusProvider, statusUpdater: StatusUpdater, private val statusEntityAdapter: ActivityPubStatusAdapter, private val platformRepo: ActivityPubPlatformRepo, private val statusUiStateAdapter: StatusUiStateAdapter, private val clientManager: ActivityPubClientManager, private val refactorToNewStatus: RefactorToNewStatusUseCase, private val loggedAccountProvider: LoggedAccountProvider, val tabType: UserTimelineTabType, val locator: PlatformLocator, val webFinger: WebFinger, val userId: String?, ) : SubViewModel(), IFeedsViewModelController by FeedsViewModelController( statusProvider = statusProvider, statusUpdater = statusUpdater, statusUiStateAdapter = statusUiStateAdapter, refactorToNewStatus = refactorToNewStatus, ) { init { initController( coroutineScope = viewModelScope, locatorResolver = { locator }, loadFirstPageLocalFeeds = { Result.success(emptyList()) }, loadNewFromServerFunction = ::loadNewDataFromServer, loadMoreFunction = ::loadMoreDataFromServer, onStatusUpdate = {}, ) initFeeds(false) } private suspend fun loadNewDataFromServer(): Result { return loadUserTimeline(null) .map { RefreshResult( newStatus = it, deletedStatus = emptyList(), ) } } private suspend fun loadMoreDataFromServer(maxId: String): Result> { return loadUserTimeline(maxId) } private suspend fun loadUserTimeline(maxId: String? = null): Result> { val loggedAccount = locator.accountUri?.let { loggedAccountProvider.getAccount(it) } val accountId = if (userId.isNullOrEmpty()) { val accountIdResult = webFingerBaseUrlToUserIdRepo.getUserId(webFinger, locator) if (accountIdResult.isFailure) { return Result.failure(accountIdResult.exceptionOrNull()!!) } accountIdResult.getOrThrow() } else { userId } val platformResult = platformRepo.getPlatform(locator) if (platformResult.isFailure) { return Result.failure(platformResult.exceptionOrNull()!!) } val platform = platformResult.getOrThrow() return fetchStatus( accountId = accountId, maxId = maxId, ).map { data -> data.filter { it.id != maxId }.map { item -> item.toUiState(loggedAccount, platform) } } } private suspend fun fetchStatus( accountId: String, maxId: String?, ): Result> { val showPinned = tabType == UserTimelineTabType.POSTS val pinnedStatus = mutableListOf() val accountRepo = clientManager.getClient(locator).accountRepo if (showPinned && maxId == null) { // first page val pinnedStatusResult = accountRepo.getStatuses( id = accountId, pinned = true, excludeReplies = true, onlyMedia = false, ) if (pinnedStatusResult.isFailure) { return Result.failure(pinnedStatusResult.exceptionOrNull()!!) } pinnedStatus += pinnedStatusResult.getOrThrow().map { it.copy(pinned = true) } } return accountRepo.getStatuses( id = accountId, pinned = false, maxId = maxId, excludeReplies = tabType != UserTimelineTabType.REPLIES, onlyMedia = tabType == UserTimelineTabType.MEDIA, ).map { pinnedStatus + it } } private suspend fun ActivityPubStatusEntity.toUiState( loggedAccount: ActivityPubLoggedAccount?, platform: BlogPlatform, ): StatusUiState { val status = statusEntityAdapter.toStatusUiState( entity = this, locator = locator, platform = platform, loggedAccount = loggedAccount, ) status.status.preParseStatus() return status } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/source/UserSourceTransformer.kt ================================================ package com.zhangke.fread.activitypub.app.internal.source import com.zhangke.activitypub.entities.ActivityPubAccountEntity import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.utils.prettyHandle import com.zhangke.fread.status.model.createActivityPubProtocol import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubAccountEntityAdapter import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubCustomEmojiEntityAdapter import com.zhangke.fread.activitypub.app.internal.uri.UserUriTransformer import com.zhangke.fread.activitypub.app.internal.usecase.emoji.MapCustomEmojiUseCase import com.zhangke.fread.status.source.StatusSource class UserSourceTransformer ( private val userUriTransformer: UserUriTransformer, private val accountEntityAdapter: ActivityPubAccountEntityAdapter, private val mapCustomEmoji: MapCustomEmojiUseCase, private val emojiEntityAdapter: ActivityPubCustomEmojiEntityAdapter, ) { suspend fun createByUserEntity(entity: ActivityPubAccountEntity): StatusSource { val webFinger = accountEntityAdapter.toWebFinger(entity) val uri = userUriTransformer.build(webFinger, FormalBaseUrl.parse(entity.url)!!) val emojis = entity.emojis.map(emojiEntityAdapter::toEmoji) return StatusSource( uri = uri, name = entity.displayName, handle = entity.acct.prettyHandle(), description = mapCustomEmoji(entity.note, emojis), thumbnail = entity.avatar, protocol = createActivityPubProtocol(), ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/uri/ActivityPubUriHost.kt ================================================ package com.zhangke.fread.activitypub.app.internal.uri import com.zhangke.fread.status.uri.FormalUri const val ACTIVITY_PUB_HOST = "activitypub.com" fun createActivityPubUri(path: String, queries: Map): FormalUri { return FormalUri.create( host = ACTIVITY_PUB_HOST, path = path, queries = queries, ) } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/uri/ActivityPubUriPath.kt ================================================ package com.zhangke.fread.activitypub.app.internal.uri object ActivityPubUriPath { const val USER = "/user" const val TIMELINE = "/timeline" const val PLATFORM = "/platform" } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/uri/PlatformUriTransformer.kt ================================================ package com.zhangke.fread.activitypub.app.internal.uri import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.model.PlatformUriInsights import com.zhangke.fread.status.uri.FormalUri class PlatformUriTransformer () { companion object { private const val QUERY_SERVER_BASE_URL = "serverBaseUrl" } fun build(serverBaseUrl: FormalBaseUrl): FormalUri { val queries = mutableMapOf() queries[QUERY_SERVER_BASE_URL] = serverBaseUrl.toString() return createActivityPubUri( path = ActivityPubUriPath.PLATFORM, queries = queries, ) } fun parse(uri: FormalUri): PlatformUriInsights? { if (!uri.isActivityPubUri) return null if (uri.path != ActivityPubUriPath.PLATFORM) return null val serverBaseUrl = uri.queries[QUERY_SERVER_BASE_URL] if (serverBaseUrl.isNullOrEmpty()) return null return PlatformUriInsights(uri, FormalBaseUrl.parse(serverBaseUrl)!!) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/uri/StatusProviderUriExts.kt ================================================ package com.zhangke.fread.activitypub.app.internal.uri import com.zhangke.fread.status.uri.FormalUri val FormalUri.isActivityPubUri: Boolean get() = host == ACTIVITY_PUB_HOST ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/uri/UserUriTransformer.kt ================================================ package com.zhangke.fread.activitypub.app.internal.uri import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.activitypub.app.internal.model.UserUriInsights import com.zhangke.fread.status.uri.FormalUri class UserUriTransformer () { companion object { private const val QUERY_FINGER = "finger" private const val QUERY_BASE_URL = "baseUrl" } fun parse(uri: FormalUri): UserUriInsights? { if (!uri.isActivityPubUri) return null if (uri.path != ActivityPubUriPath.USER) return null val webFinger = uri.queries[QUERY_FINGER]?.let { WebFinger.create(it) } ?: return null val baseUrl = uri.queries[QUERY_BASE_URL]?.let { FormalBaseUrl.parse(it) } ?: return null return UserUriInsights( uri = uri, webFinger = webFinger, baseUrl = baseUrl, ) } fun build( webFinger: WebFinger, baseUrl: FormalBaseUrl, ): FormalUri { val queries = mutableMapOf() queries[QUERY_FINGER] = webFinger.toString() queries[QUERY_BASE_URL] = baseUrl.toString() return createActivityPubUri( path = ActivityPubUriPath.USER, queries = queries, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/ActivityPubAccountLogoutUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.usecase import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.activitypub.app.internal.push.ActivityPubPushManager import com.zhangke.fread.activitypub.app.internal.repo.account.ActivityPubLoggedAccountRepo import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.uri.FormalUri class ActivityPubAccountLogoutUseCase ( private val activityPubPushManager: ActivityPubPushManager, private val accountRepo: ActivityPubLoggedAccountRepo, private val loggedAccountProvider: LoggedAccountProvider, ) { suspend operator fun invoke(account: ActivityPubLoggedAccount) { invoke( baseUrl = account.platform.baseUrl, accountUri = account.uri, userId = account.userId, ) } suspend operator fun invoke( baseUrl: FormalBaseUrl, accountUri: FormalUri, userId: String, ) { val role = PlatformLocator(baseUrl = baseUrl, accountUri = accountUri) activityPubPushManager.unsubscribe(role, userId) accountRepo.deleteByUri(accountUri) loggedAccountProvider.removeAccount(accountUri) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/GetDefaultBaseUrlUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.usecase import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider class GetDefaultBaseUrlUseCase ( private val loggedAccountProvider: LoggedAccountProvider, ) { companion object { private val defaultBaseUrl = FormalBaseUrl.Companion.parse("https://mastodon.online")!! } operator fun invoke(): FormalBaseUrl { loggedAccountProvider.getAllAccounts().firstOrNull()?.let { return it.platform.baseUrl } return defaultBaseUrl } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/GetInstanceAnnouncementUseCase.kt ================================================ @file:OptIn(ExperimentalTime::class) package com.zhangke.fread.activitypub.app.internal.usecase import com.zhangke.activitypub.entities.ActivityPubAnnouncementEntity import com.zhangke.framework.date.DateParser import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.common.utils.getCurrentTimeMillis import com.zhangke.fread.status.model.PlatformLocator import kotlin.time.ExperimentalTime class GetInstanceAnnouncementUseCase ( private val clientManager: ActivityPubClientManager, ) { suspend operator fun invoke( baseUrl: FormalBaseUrl, justActive: Boolean = true, ): Result> { val client = clientManager.getClient(PlatformLocator(baseUrl = baseUrl)) return client.instanceRepo.getAnnouncement() .map { if (justActive) { it.filter { item -> item.isActive() } } else { it } } } private fun ActivityPubAnnouncementEntity.isActive(): Boolean { val startDateTime = startsAt?.let { DateParser.parseOrCurrent(it) }?.instant?.toEpochMilliseconds() val endDateTime = endsAt?.let { DateParser.parseOrCurrent(it) }?.instant?.toEpochMilliseconds() if (startDateTime == null) return true val currentDateTime = getCurrentTimeMillis() if (currentDateTime < startDateTime) return false if (endDateTime == null && allDay) return true if (endDateTime == null) return true return currentDateTime < endDateTime } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/GetServerTrendTagsUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.usecase import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubTagAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.status.model.Hashtag import com.zhangke.fread.status.model.PlatformLocator class GetServerTrendTagsUseCase ( private val clientManager: ActivityPubClientManager, private val activityPubTagAdapter: ActivityPubTagAdapter, ) { suspend operator fun invoke(locator: PlatformLocator): Result> { return clientManager.getClient(locator) .instanceRepo .getTrendsTags( limit = 10, offset = 0, ).map { list -> list.map { activityPubTagAdapter.adapt(it) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/UpdateActivityPubUserListUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.usecase import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent.ContentTab import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent.ContentTab.ListTimeline import com.zhangke.fread.common.content.FreadContentRepo class UpdateActivityPubUserListUseCase ( private val contentRepo: FreadContentRepo, ) { suspend operator fun invoke( content: ActivityPubContent, allUserCreatedList: List ) { val allListIdSet = allUserCreatedList.map { it.listId }.toSet() val localListIdSet = content.tabList .filterIsInstance() .map { it.listId } .toSet() val newTabList = content.tabList.dropNotExistListTab(allListIdSet).toMutableList() var maxOrder = content.tabList.maxByOrNull { it.order }?.order ?: 0 allUserCreatedList.filter { it.listId !in localListIdSet } .map { ListTimeline(it.listId, it.name, maxOrder++) } .let { newTabList.addAll(it) } if (content.tabList.sortedBy { it.order } == newTabList.sortedBy { it.order }) { return } content.copy(tabList = newTabList).let { contentRepo.insertContent(it) } } private fun List.dropNotExistListTab( allListId: Set ): List { return this.filter { if (it is ListTimeline) { it.listId in allListId } else { true } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/content/GetUserCreatedListUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.usecase.content import com.zhangke.activitypub.entities.ActivityPubListEntity import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.status.model.PlatformLocator class GetUserCreatedListUseCase ( private val clientManager: ActivityPubClientManager ) { suspend operator fun invoke(locator: PlatformLocator): Result> { return clientManager.getClient(locator) .listsRepo .getAccountLists() } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/content/ReorderActivityPubTabUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.usecase.content import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.common.content.FreadContentRepo class ReorderActivityPubTabUseCase ( private val contentRepo: FreadContentRepo, ) { suspend operator fun invoke( content: ActivityPubContent, fromTab: ActivityPubContent.ContentTab, toTab: ActivityPubContent.ContentTab, ) { if (fromTab == toTab) return val newShowingList = if (fromTab.order > toTab.order) { // move up content.tabList.map { item -> if (item.order in toTab.order until fromTab.order) { item.newOrder(item.order + 1) } else if (item == fromTab) { fromTab.newOrder(order = toTab.order) } else { item } }.sortedBy { it.order } } else { // move down content.tabList.map { item -> if (item.order > fromTab.order && item.order <= toTab.order) { item.newOrder(order = item.order - 1) } else if (item == fromTab) { fromTab.newOrder(order = toTab.order) } else { item } }.sortedBy { it.order } } contentRepo.insertContent(content.copy(tabList = newShowingList)) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/emoji/GetCustomEmojiUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.usecase.emoji import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubCustomEmojiEntityAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.screen.status.post.adapter.CustomEmojiAdapter import com.zhangke.fread.activitypub.app.internal.screen.status.post.composable.GroupedCustomEmojiCell import com.zhangke.fread.status.model.PlatformLocator class GetCustomEmojiUseCase ( private val clientManager: ActivityPubClientManager, private val emojiEntityAdapter: ActivityPubCustomEmojiEntityAdapter, private val emojiAdapter: CustomEmojiAdapter, ) { suspend operator fun invoke(locator: PlatformLocator): Result> { return clientManager.getClient(locator) .emojiRepo .getCustomEmojis() .map { list -> list.map(emojiEntityAdapter::toCustomEmoji) } .map { emojiAdapter.toEmojiCell(it) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/emoji/MapCustomEmojiUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.usecase.emoji import com.zhangke.fread.status.model.Emoji class MapCustomEmojiUseCase () { operator fun invoke(content: String, emojis: List): String { return content // var mappedContent = content // emojis.forEach { // mappedContent = // content.replace( // ":${it.shortcode}:", // "\"${it.shortcode}\"", // ) // } // return mappedContent } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/media/UploadMediaAttachmentUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.usecase.media import com.zhangke.activitypub.api.MediaRepo import com.zhangke.activitypub.entities.ActivityPubMediaAttachmentEntity import com.zhangke.activitypub.entities.ActivityPubResponse import com.zhangke.framework.utils.ContentProviderFile import com.zhangke.framework.utils.exceptionOrThrow import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.status.model.PlatformLocator import io.ktor.http.HttpStatusCode import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.seconds class UploadMediaAttachmentUseCase ( private val clientManager: ActivityPubClientManager, ) { suspend operator fun invoke( locator: PlatformLocator, file: ContentProviderFile, onProgress: (Float) -> Unit = {}, ): Result { val mediaRepo = clientManager.getClient(locator).mediaRepo val response = mediaRepo.uploadFile(file, onProgress) if (response.isFailure) return Result.failure(response.exceptionOrThrow()) val (code, attachment) = response.getOrThrow() if (code != HttpStatusCode.Accepted.value) return Result.success(attachment.id) var repeatCount = 0 val interval = 3.seconds while (repeatCount < 10) { val mediaResponse = mediaRepo.getMedia(attachment.id) if (mediaResponse.isFailure || mediaResponse.getOrNull()?.code == HttpStatusCode.PartialContent.value) { delay(interval) } else { return mediaResponse.map { it.response.id } } repeatCount++ } return Result.failure(RuntimeException("File upload failed, processing timeout!")) } private suspend fun MediaRepo.uploadFile( file: ContentProviderFile, onProgress: (Float) -> Unit = {}, ): Result> { return try { val bytes = file.readBytes() ?: return Result.failure(RuntimeException("File invalid!")) this.postFile( fileName = file.fileName, fileSize = file.size.bytes, byteArray = bytes, fileMediaType = file.mimeType, onProgress = onProgress, ) } catch (t: Throwable) { Result.failure(RuntimeException("File invalid!")) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/platform/GetInstancePostStatusRulesUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.usecase.platform import com.zhangke.activitypub.entities.ActivityPubInstanceEntity import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.screen.status.post.PostBlogRules import com.zhangke.fread.status.model.PlatformLocator class GetInstancePostStatusRulesUseCase ( private val clientManager: ActivityPubClientManager, ) { suspend operator fun invoke(locator: PlatformLocator): Result { return clientManager.getClient(locator) .instanceRepo .getInstanceInformation() .map { it.toRule() } } private fun ActivityPubInstanceEntity.toRule(): PostBlogRules { val config = configuration return PostBlogRules.default( maxCharacters = config.statuses?.maxCharacters ?: 0, maxMediaCount = config.statuses?.maxMediaAttachments ?: 0, maxPollOptions = config.polls?.maxOptions ?: 0, supportsQuotePost = this.supportsQuotePost, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/source/user/SearchUserSourceNoTokenUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.usecase.source.user import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.network.HttpScheme import com.zhangke.framework.utils.WebFinger import com.zhangke.framework.utils.toPlatformUri import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.repo.user.UserRepo import com.zhangke.fread.activitypub.app.internal.source.UserSourceTransformer import com.zhangke.fread.activitypub.app.internal.uri.UserUriTransformer import com.zhangke.fread.activitypub.app.internal.usecase.GetDefaultBaseUrlUseCase import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.source.StatusSource import com.zhangke.fread.status.uri.FormalUri class SearchUserSourceNoTokenUseCase ( private val clientManager: ActivityPubClientManager, private val userRepo: UserRepo, private val userUriTransformer: UserUriTransformer, private val userSourceTransformer: UserSourceTransformer, private val getDefaultBaseUrl: GetDefaultBaseUrlUseCase, ) { suspend operator fun invoke( query: String, ): Result> { val locator = PlatformLocator(baseUrl = getDefaultBaseUrl()) searchAsUserUri(locator, query).getOrNull()?.let { return Result.success(listOf(it)) } searchAsWebFinger(locator, query).getOrNull()?.let { return Result.success(listOf(it)) } searchAsUrl(query).getOrNull()?.let { return Result.success(listOf(it)) } return searchUser(locator, query) } private suspend fun searchAsUserUri( locator: PlatformLocator, query: String ): Result { FormalUri.from(query) ?.let(userUriTransformer::parse) ?.let { userRepo.getUserSource( locator = locator, userUriInsights = it, ).getOrNull() }?.let { return Result.success(it) } return Result.success(null) } private suspend fun searchAsWebFinger( locator: PlatformLocator, query: String, ): Result { val webFinger = WebFinger.create(query) ?: return Result.success(null) return userRepo.lookupUserSource( locator = locator, acct = webFinger.toString(), ) } /** * https://m.cmx.im/@webb@androiddev.social * https://androiddev.social/@webb */ private suspend fun searchAsUrl(query: String): Result { WebFinger.create(query) ?: return Result.success(null) // FIXME: fix no scheme query val uri = query.toPlatformUri() val baseUrl = FormalBaseUrl.parse(query) ?: return Result.success(null) val scheme = if (uri.scheme.isNullOrEmpty()) HttpScheme.HTTPS else "${uri.scheme}://" if (!HttpScheme.validate(scheme)) return Result.success(null) val host = uri.host if (host.isNullOrEmpty()) return Result.success(null) val acct = uri.path ?.removePrefix("/") ?.removeSuffix("/") if (acct.isNullOrEmpty()) return Result.success(null) val locator = PlatformLocator(baseUrl = baseUrl) return userRepo.lookupUserSource( locator = locator, acct = acct, ) } private suspend fun searchUser( locator: PlatformLocator, query: String ): Result> { return clientManager.getClient(locator) .searchRepo.queryAccount(query = query) .map { list -> list.map { userSourceTransformer.createByUserEntity(it) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/status/GetStatusContextUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.usecase.status import com.zhangke.activitypub.entities.ActivityPubStatusContextEntity import com.zhangke.activitypub.entities.ActivityPubStatusEntity import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.model.ActivityPubLoggedAccount import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.status.model.DescendantStatus import com.zhangke.fread.status.status.model.Status import com.zhangke.fread.status.status.model.StatusContext class GetStatusContextUseCase ( private val clientManager: ActivityPubClientManager, private val statusAdapter: ActivityPubStatusAdapter, private val loggedAccountProvider: LoggedAccountProvider, ) { suspend operator fun invoke( locator: PlatformLocator, status: Status, ): Result { val loggedAccount = loggedAccountProvider.getAccount(locator) val statusId = status.id return clientManager.getClient(locator) .statusRepo .getStatusContext(statusId) .map { convert( parentId = statusId, entity = it, platform = status.platform, locator = locator, loggedAccount = loggedAccount, ) } } private suspend fun convert( parentId: String, entity: ActivityPubStatusContextEntity, platform: BlogPlatform, locator: PlatformLocator, loggedAccount: ActivityPubLoggedAccount?, ): StatusContext { return StatusContext( ancestors = entity.ancestors.toStatusList( locator = locator, platform = platform, loggedAccount = loggedAccount, ), status = null, descendants = convertDescendantsStatus( parentId = parentId, descendants = entity.descendants, platform = platform, locator = locator, loggedAccount = loggedAccount, ), ) } private suspend fun convertDescendantsStatus( parentId: String, descendants: List, platform: BlogPlatform, locator: PlatformLocator, loggedAccount: ActivityPubLoggedAccount?, ): List { if (descendants.isEmpty()) return emptyList() val firstLevelDescendants = descendants.filter { it.inReplyToId == parentId } return firstLevelDescendants.map { entity -> DescendantStatus( entity.toUiState(locator, platform, loggedAccount), buildDescendantsStatus(entity.id, descendants, platform, locator, loggedAccount) ) } } private suspend fun buildDescendantsStatus( parentId: String, descendants: List, platform: BlogPlatform, locator: PlatformLocator, loggedAccount: ActivityPubLoggedAccount?, ): DescendantStatus? { if (descendants.isEmpty()) return null val descentEntity = descendants.firstOrNull { it.inReplyToId == parentId } ?: return null val descendantDescendant = buildDescendantsStatus(descentEntity.id, descendants, platform, locator, loggedAccount) return DescendantStatus( status = descentEntity.toUiState(locator, platform, loggedAccount), descendantStatus = descendantDescendant, ) } private suspend fun List.toStatusList( locator: PlatformLocator, platform: BlogPlatform, loggedAccount: ActivityPubLoggedAccount?, ): List { return this.map { statusEntity -> statusEntity.toUiState( platform = platform, loggedAccount = loggedAccount, locator = locator, ) } } private suspend fun ActivityPubStatusEntity.toUiState( locator: PlatformLocator, platform: BlogPlatform, loggedAccount: ActivityPubLoggedAccount?, ): StatusUiState { return statusAdapter.toStatusUiState( entity = this, platform = platform, loggedAccount = loggedAccount, locator = locator, ) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/status/GetTimelineStatusUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.usecase.status import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.model.ActivityPubStatusSourceType import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.status.model.Status class GetTimelineStatusUseCase ( private val clientManager: ActivityPubClientManager, private val platformRepo: ActivityPubPlatformRepo, private val statusAdapter: ActivityPubStatusAdapter, ) { suspend operator fun invoke( locator: PlatformLocator, type: ActivityPubStatusSourceType, maxId: String?, minId: String?, limit: Int, listId: String? = null, ): Result> { val timelineRepo = clientManager.getClient(locator).timelinesRepo val platformResult = platformRepo.getPlatform(locator) if (platformResult.isFailure) { return Result.failure(platformResult.exceptionOrNull()!!) } val platform = platformResult.getOrThrow() val entitiesResult = when (type) { ActivityPubStatusSourceType.TIMELINE_HOME -> timelineRepo.homeTimeline( limit = limit, minId = minId, maxId = maxId, ) ActivityPubStatusSourceType.TIMELINE_LOCAL -> timelineRepo.localTimelines( limit = limit, minId = minId, maxId = maxId, ) ActivityPubStatusSourceType.TIMELINE_PUBLIC -> timelineRepo.publicTimelines( limit = limit, minId = minId, maxId = maxId, ) ActivityPubStatusSourceType.LIST -> timelineRepo.getTimelineList( listId = listId!!, limit = limit, minId = minId, maxId = maxId, ) } return entitiesResult.map { list -> list.filter { it.id != minId && it.id != maxId } .map { statusAdapter.toStatus(it, platform) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/status/GetUserStatusUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.usecase.status import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.activitypub.app.internal.auth.LoggedAccountProvider import com.zhangke.fread.activitypub.app.internal.model.UserUriInsights import com.zhangke.fread.activitypub.app.internal.repo.WebFingerBaseUrlToUserIdRepo import com.zhangke.fread.activitypub.app.internal.repo.platform.ActivityPubPlatformRepo import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState class GetUserStatusUseCase ( private val clientManager: ActivityPubClientManager, private val webFingerBaseUrlToUserIdRepo: WebFingerBaseUrlToUserIdRepo, private val activityPubStatusAdapter: ActivityPubStatusAdapter, private val platformRepo: ActivityPubPlatformRepo, private val loggedAccountProvider: LoggedAccountProvider, ) { suspend operator fun invoke( locator: PlatformLocator, userInsights: UserUriInsights, limit: Int, maxId: String?, ): Result> { val userIdResult = webFingerBaseUrlToUserIdRepo.getUserId(userInsights.webFinger, locator) if (userIdResult.isFailure) return Result.failure(userIdResult.exceptionOrNull()!!) val userId = userIdResult.getOrThrow() val platformResult = platformRepo.getPlatform(locator) if (platformResult.isFailure) return Result.failure(platformResult.exceptionOrNull()!!) val platform = platformResult.getOrThrow() val account = loggedAccountProvider.getAccount(locator) return clientManager.getClient(locator) .accountRepo.getStatuses( id = userId, limit = limit, maxId = maxId, ).map { list -> list.map { activityPubStatusAdapter.toStatusUiState( entity = it, platform = platform, locator = locator, loggedAccount = account, ) } } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/status/StatusInteractiveUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.usecase.status import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubStatusAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusActionType import com.zhangke.fread.status.status.model.Status class StatusInteractiveUseCase ( private val clientManager: ActivityPubClientManager, private val activityPubStatusAdapter: ActivityPubStatusAdapter, ) { suspend operator fun invoke( locator: PlatformLocator, status: Status, type: StatusActionType, ): Result { val statusId = if (status is Status.Reblog) { status.reblog.id } else { status.id } val statusRepo = clientManager.getClient(locator).statusRepo val blog = status.intrinsicBlog val interactionResult = when (type) { StatusActionType.LIKE -> { if (blog.like.liked == true) { statusRepo.unfavourite(statusId) } else { statusRepo.favourite(statusId) } } StatusActionType.FORWARD -> { if (blog.forward.forward == true) { statusRepo.unreblog(statusId) } else { statusRepo.reblog(statusId) } } StatusActionType.BOOKMARK -> { if (blog.bookmark.bookmarked == true) { statusRepo.unbookmark(statusId) } else { statusRepo.bookmark(statusId) } } StatusActionType.DELETE -> statusRepo.delete(statusId) StatusActionType.PIN -> { if (blog.pinned) { statusRepo.unpin(statusId) } else { statusRepo.pin(statusId) } } else -> { Result.failure(IllegalArgumentException("Unknown interaction: $type")) } } if (interactionResult.isFailure) { return Result.failure(interactionResult.exceptionOrNull()!!) } if (type == StatusActionType.DELETE) { return Result.success(null) } val resultNewStatusEntity = interactionResult.getOrThrow() val newStatus = activityPubStatusAdapter.toStatus( resultNewStatusEntity, status.platform, ) val resultStatus = if (status is Status.Reblog) { status.copy(reblog = newStatus.intrinsicBlog) } else { newStatus } return Result.success(resultStatus) } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/usecase/status/VotePollUseCase.kt ================================================ package com.zhangke.fread.activitypub.app.internal.usecase.status import com.zhangke.fread.activitypub.app.internal.adapter.ActivityPubPollAdapter import com.zhangke.fread.activitypub.app.internal.auth.ActivityPubClientManager import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.blog.BlogPoll import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.status.model.Status class VotePollUseCase ( private val clientManager: ActivityPubClientManager, private val pollAdapter: ActivityPubPollAdapter, ) { suspend operator fun invoke( locator: PlatformLocator, blog: Blog, votedOption: List, ): Result { return clientManager.getClient(locator) .statusRepo .votes( id = blog.poll!!.id, choices = votedOption.map { it.index }, ) .map { pollAdapter.adapt(it) } .map { poll -> Status.NewBlog(blog.copy(poll = poll)) } } } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/utils/Base64Utils.kt ================================================ package com.zhangke.fread.activitypub.app.internal.utils import io.ktor.utils.io.core.toByteArray import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi // FIXME: Support Base64.NO_WRAP @OptIn(ExperimentalEncodingApi::class) internal fun String.encodeToBase64(): String { return Base64.UrlSafe.encode(this.toByteArray()) } // FIXME: Support Base64.NO_WRAP @OptIn(ExperimentalEncodingApi::class) internal fun String.decodeFromBase64(): String { return Base64.UrlSafe.decode(this).decodeToString() } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/utils/DeleteTextUtil.kt ================================================ package com.zhangke.fread.activitypub.app.internal.utils import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue object DeleteTextUtil { fun deleteText(text: TextFieldValue): TextFieldValue { if (text.text.isEmpty()) return text val startIndex = text.selection.start if (text.selection.length > 0) { val newText = text.text.remove(text.selection) return TextFieldValue(newText, TextRange(startIndex)) } val range = findBeforeEmoji(text.text, text.selection.start)?.toTextRange() if (range != null) { val newText = text.text.remove(range) return TextFieldValue(newText, TextRange(range.start)) } if (startIndex > 0) { val removedRange = TextRange(startIndex - 1, startIndex) return TextFieldValue(text.text.remove(removedRange), TextRange(startIndex - 1)) } return text } private fun IntRange.toTextRange(): TextRange { return TextRange(start, endInclusive) } private fun String.remove(range: TextRange): String { if (this.isEmpty()) return this if (range.length < 0) return this if (range.start < 0 || range.end > this.length) return this val prefix = this.substring(0, range.start) val suffix = this.substring(range.end) return prefix + suffix } // must be space private const val STATE_INIT = 0 // must be colon private const val STATE_SCANNING_START_COLON = 1 // can be anything but blank private const val STATE_SCANNING_CODE = 2 // must be colon private const val STATE_SCANNING_END_COLON = 3 // must be space private const val STATE_SCANNING_END_SPACE = 4 // 判断 index 之前是否是 emoji internal fun findBeforeEmoji(text: String, index: Int): IntRange? { //“ :1234_iu01: ” if (index <= 0 || index > text.length) return null var currentIndex = index - 1 var state = STATE_INIT val charArray = text.toCharArray() while (currentIndex >= 0) { val char = charArray[currentIndex] when (state) { STATE_INIT -> { if (char.isSpace) { state = STATE_SCANNING_START_COLON currentIndex-- } else { return null } } STATE_SCANNING_START_COLON -> { if (char.isColon) { state = STATE_SCANNING_CODE currentIndex-- } else { return null } } STATE_SCANNING_CODE -> { if (char.validateCode) { currentIndex-- } else if (char.isColon) { state = STATE_SCANNING_END_COLON } else { return null } } STATE_SCANNING_END_COLON -> { if (char.isColon) { state = STATE_SCANNING_END_SPACE currentIndex-- } else { return null } } STATE_SCANNING_END_SPACE -> { return if (char.isSpace) { IntRange(currentIndex, index) } else { null } } } } return null } private val Char.isSpace: Boolean get() = this == ' ' private val Char.isColon: Boolean get() = this == ':' private val Char.validateCode: Boolean get() = this.isLetterOrDigit() || this == '_' } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/utils/MastodonHelper.kt ================================================ package com.zhangke.fread.activitypub.app.internal.utils import com.zhangke.fread.activitypub.app.Res import com.zhangke.fread.common.utils.StorageHelper import okio.FileSystem import okio.Path.Companion.toPath import okio.SYSTEM import okio.openZip import org.jetbrains.compose.resources.ExperimentalResourceApi class MastodonHelper ( private val storageHelper: StorageHelper, ) { private val fileSystem = FileSystem.SYSTEM @OptIn(ExperimentalResourceApi::class) suspend fun getLocalMastodonJson(): String? = runCatching { val cacheMastodonServersZipPath = storageHelper.cacheDir.resolve("mastodon-servers.zip") // Res.getUri is not available, so copy to cache dir if (!fileSystem.exists(cacheMastodonServersZipPath)) { fileSystem.write(cacheMastodonServersZipPath) { write(Res.readBytes("files/mastodon-servers.zip")) } } val zip = FileSystem.SYSTEM.openZip(cacheMastodonServersZipPath) return zip.read("servers.json".toPath()) { readUtf8() } }.getOrNull() } ================================================ FILE: plugins/activitypub-app/src/commonMain/kotlin/com/zhangke/fread/activitypub/app/internal/utils/PlatformLocatorUtils.kt ================================================ package com.zhangke.fread.activitypub.app.internal.utils import com.zhangke.fread.activitypub.app.internal.content.ActivityPubContent import com.zhangke.fread.status.model.PlatformLocator fun createPlatformLocator(content: ActivityPubContent): PlatformLocator { return PlatformLocator(baseUrl = content.baseUrl, accountUri = content.accountUri) } ================================================ FILE: plugins/activitypub-app/src/iosMain/kotlin/com/zhangke/fread/activitypub/app/ActivityPubIosModule.kt ================================================ package com.zhangke.fread.activitypub.app import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.zhangke.fread.activitypub.app.internal.db.ActivityPubDatabases import com.zhangke.fread.activitypub.app.internal.db.ActivityPubLoggedAccountDatabase import com.zhangke.fread.activitypub.app.internal.db.status.ActivityPubStatusDatabases import com.zhangke.fread.activitypub.app.internal.db.status.ActivityPubStatusReadStateDatabases import com.zhangke.fread.activitypub.app.internal.push.ActivityPubPushManager import com.zhangke.fread.common.documentDirectory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import org.koin.core.module.Module import org.koin.core.module.dsl.factoryOf actual fun Module.createPlatformModule() { factory { val dbFilePath = getDBFilePath(ActivityPubDatabases.DB_NAME) Room.databaseBuilder( name = dbFilePath, ).setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() } single { val dbFilePath = getDBFilePath(ActivityPubLoggedAccountDatabase.DB_NAME) Room.databaseBuilder( name = dbFilePath, ).setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() } factory { val dbFilePath = getDBFilePath(ActivityPubStatusDatabases.DB_NAME) Room.databaseBuilder( name = dbFilePath, ).setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .addMigrations(ActivityPubStatusDatabases.MIGRATION_1_2) .build() } factory { val dbFilePath = getDBFilePath(ActivityPubStatusReadStateDatabases.DB_NAME) Room.databaseBuilder( name = dbFilePath, ).setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() } factoryOf(::ActivityPubPushManager) } private fun getDBFilePath(dbName: String): String { return documentDirectory() + "/$dbName" } ================================================ FILE: plugins/activitypub-app/src/iosMain/kotlin/com/zhangke/fread/activitypub/app/internal/push/ActivityPubPushManager.ios.kt ================================================ package com.zhangke.fread.activitypub.app.internal.push import com.zhangke.fread.status.model.PlatformLocator actual class ActivityPubPushManager() { actual suspend fun subscribe(locator: PlatformLocator, accountId: String) { throw NotImplementedError("Not implemented for iOS") } actual suspend fun unsubscribe(locator: PlatformLocator, accountId: String) { throw NotImplementedError("Not implemented for iOS") } } ================================================ FILE: plugins/activitypub-app/src/iosMain/kotlin/com/zhangke/fread/activitypub/app/internal/push/ActivityPubPushMessageReceiverHelper.ios.kt ================================================ package com.zhangke.fread.activitypub.app.internal.push import com.zhangke.fread.common.push.PushMessage actual class ActivityPubPushMessageReceiverHelper { actual fun onReceiveNewMessage(message: PushMessage) { error("Not implemented") } } ================================================ FILE: plugins/bluesky/.gitignore ================================================ /build ================================================ FILE: plugins/bluesky/build.gradle.kts ================================================ plugins { id("fread.project.feature.kmp") id("com.google.devtools.ksp") id("kotlin-parcelize") alias(libs.plugins.room) } android { namespace = "com.zhangke.fread.bluesky" sourceSets { getByName("main") { assets.srcDirs("src/androidMain/assets") res.srcDirs("src/androidMain/res") manifest.srcFile("src/androidMain/AndroidManifest.xml") } } } kotlin { sourceSets { commonMain { dependencies { implementation(project(path = ":framework")) implementation(project(path = ":commonbiz:common")) implementation(project(path = ":bizframework:status-provider")) implementation(project(path = ":commonbiz:status-ui")) implementation(project(path = ":commonbiz:sharedscreen")) implementation(project(":commonbiz:analytics")) implementation(compose.components.resources) implementation(libs.arrow.core) implementation(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.serialization.kotlinx.json) implementation(libs.ktor.client.logging) implementation(libs.jetbrains.lifecycle.viewmodel) implementation(libs.androidx.constraintlayout.compose.kmp) implementation(libs.imageLoader) implementation(libs.androidx.room) implementation(libs.auto.service.annotations) implementation(libs.androidx.paging.common) implementation(libs.leftright) implementation(libs.haze) implementation(libs.krouter.runtime) api(libs.bluesky) } } commonTest { dependencies { implementation(kotlin("test")) } } androidMain { dependencies { implementation(libs.androidx.core.ktx) implementation(libs.bundles.androidx.activity) implementation(libs.androidx.browser) } } } configureCommonMainKsp() } dependencies { kspAll(libs.androidx.room.compiler) kspAll(libs.auto.service.ksp) kspAll(libs.krouter.collecting.compiler) } compose { resources { publicResClass = false packageOfResClass = "com.zhangke.fread.bluesky" generateResClass = always } } room { schemaDirectory("$projectDir/schemas") } ================================================ FILE: plugins/bluesky/consumer-rules.pro ================================================ ================================================ FILE: plugins/bluesky/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: plugins/bluesky/src/androidMain/kotlin/com/zhangke/fread/bluesky/BlueskyAndroidModule.kt ================================================ package com.zhangke.fread.bluesky import androidx.room.Room import com.zhangke.fread.bluesky.internal.db.BlueskyLoggedAccountDatabase import org.koin.android.ext.koin.androidContext import org.koin.core.module.Module actual fun Module.createPlatformModule() { single { Room.databaseBuilder( androidContext(), BlueskyLoggedAccountDatabase::class.java, BlueskyLoggedAccountDatabase.DB_NAME, ).build() } } ================================================ FILE: plugins/bluesky/src/androidTest/java/com/zhangke/fread/bluesky/ExampleInstrumentedTest.kt ================================================ package com.zhangke.fread.bluesky import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Test import org.junit.runner.RunWith import org.junit.Assert.* /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { @Test fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.zhangke.fread.bluesky.test", appContext.packageName) } } ================================================ FILE: plugins/bluesky/src/commonMain/composeResources/drawable/bluesky_logo.xml ================================================ ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/BlueskyAccountManager.kt ================================================ package com.zhangke.fread.bluesky import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccount import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccountManager import com.zhangke.fread.bluesky.internal.content.BlueskyContent import com.zhangke.fread.bluesky.internal.screen.add.AddBlueskyContentScreenNavKey import com.zhangke.fread.bluesky.internal.usecase.UnblockUserWithoutUriUseCase import com.zhangke.fread.common.utils.GlobalScreenNavigation import com.zhangke.fread.status.account.AccountRefreshResult import com.zhangke.fread.status.account.IAccountManager import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.FreadContent import com.zhangke.fread.status.model.LoggedAccountDetail import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.Relationships import com.zhangke.fread.status.model.notBluesky import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.uri.FormalUri import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map class BlueskyAccountManager( private val accountManager: BlueskyLoggedAccountManager, private val unblockUserWithoutUri: UnblockUserWithoutUriUseCase, ) : IAccountManager { override suspend fun getAllLoggedAccount(): List { return accountManager.getAllAccount() } override fun getAllAccountFlow(): Flow> { return accountManager.getAllAccountFlow() } override fun getAllAccountDetailFlow(): Flow>? { return accountManager.getAllAccountFlow() .map { list -> list.map { LoggedAccountDetail( account = it, author = it.user, ) } } } override suspend fun triggerLaunchAuth(platform: BlogPlatform, account: LoggedAccount?) { if (platform.protocol.notBluesky) return GlobalScreenNavigation.navigate( AddBlueskyContentScreenNavKey( baseUrl = platform.baseUrl, loginMode = true, avatar = account?.avatar, displayName = account?.userName, handle = account?.prettyHandle, ) ) } override suspend fun refreshAllAccountInfo(): List { return accountManager.refreshAccountProfile() } override suspend fun logout(account: LoggedAccount): Boolean { if (account !is BlueskyLoggedAccount) return false accountManager.logout(account.uri) return true } override fun subscribeNotification() { } override suspend fun getRelationships( account: LoggedAccount, accounts: List, ): Result> { // not implemented for Bluesky currently return Result.success(emptyMap()) } override suspend fun cancelFollowRequest( account: LoggedAccount, user: BlogAuthor ): Result? { return null } override suspend fun unblockAccount( account: LoggedAccount, user: BlogAuthor ): Result? { if (account.platform.protocol.notBluesky) return null val locator = PlatformLocator( baseUrl = account.platform.baseUrl, accountUri = account.uri, ) return unblockUserWithoutUri(locator, user) } override suspend fun selectContentWithAccount( contentList: List, account: LoggedAccount ): List { return contentList.filterIsInstance() .filter { it.baseUrl == account.platform.baseUrl } } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/BlueskyModule.kt ================================================ package com.zhangke.fread.bluesky import com.zhangke.framework.nav.NavEntryProvider import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccountManager import com.zhangke.fread.bluesky.internal.adapter.BlueskyAccountAdapter import com.zhangke.fread.bluesky.internal.adapter.BlueskyFeedsAdapter import com.zhangke.fread.bluesky.internal.adapter.BlueskyNotificationAdapter import com.zhangke.fread.bluesky.internal.adapter.BlueskyProfileAdapter import com.zhangke.fread.bluesky.internal.adapter.BlueskyStatusAdapter import com.zhangke.fread.bluesky.internal.client.BlueskyClientManager import com.zhangke.fread.bluesky.internal.content.BlueskyContentManager import com.zhangke.fread.bluesky.internal.migrate.BlueskyContentMigrator import com.zhangke.fread.bluesky.internal.repo.BlueskyLoggedAccountRepo import com.zhangke.fread.bluesky.internal.repo.BlueskyPlatformRepo import com.zhangke.fread.bluesky.internal.screen.add.AddBlueskyContentViewModel import com.zhangke.fread.bluesky.internal.screen.content.BlueskyContentContainerViewModel import com.zhangke.fread.bluesky.internal.screen.feeds.detail.FeedsDetailViewModel import com.zhangke.fread.bluesky.internal.screen.feeds.explorer.ExplorerFeedsViewModel import com.zhangke.fread.bluesky.internal.screen.feeds.following.BskyFollowingFeedsViewModel import com.zhangke.fread.bluesky.internal.screen.feeds.home.HomeFeedsContainerViewModel import com.zhangke.fread.bluesky.internal.screen.publish.PublishPostViewModel import com.zhangke.fread.bluesky.internal.screen.search.SearchStatusViewModel import com.zhangke.fread.bluesky.internal.screen.user.detail.BskyUserDetailViewModel import com.zhangke.fread.bluesky.internal.screen.user.edit.EditProfileViewModel import com.zhangke.fread.bluesky.internal.screen.user.list.UserListViewModel import com.zhangke.fread.bluesky.internal.uri.platform.PlatformUriTransformer import com.zhangke.fread.bluesky.internal.uri.user.UserUriTransformer import com.zhangke.fread.bluesky.internal.usecase.BskyStatusInteractiveUseCase import com.zhangke.fread.bluesky.internal.usecase.CreateRecordUseCase import com.zhangke.fread.bluesky.internal.usecase.DeleteRecordUseCase import com.zhangke.fread.bluesky.internal.usecase.GetAllListsUseCase import com.zhangke.fread.bluesky.internal.usecase.GetAtIdentifierUseCase import com.zhangke.fread.bluesky.internal.usecase.GetCompletedNotificationUseCase import com.zhangke.fread.bluesky.internal.usecase.GetFeedsStatusUseCase import com.zhangke.fread.bluesky.internal.usecase.GetFollowingFeedsUseCase import com.zhangke.fread.bluesky.internal.usecase.GetStatusContextUseCase import com.zhangke.fread.bluesky.internal.usecase.LoginToBskyUseCase import com.zhangke.fread.bluesky.internal.usecase.PinFeedsUseCase import com.zhangke.fread.bluesky.internal.usecase.PublishingPostUseCase import com.zhangke.fread.bluesky.internal.usecase.RefreshSessionUseCase import com.zhangke.fread.bluesky.internal.usecase.UnblockUserWithoutUriUseCase import com.zhangke.fread.bluesky.internal.usecase.UnpinFeedsUseCase import com.zhangke.fread.bluesky.internal.usecase.UpdateBlockUseCase import com.zhangke.fread.bluesky.internal.usecase.UpdateHomeTabUseCase import com.zhangke.fread.bluesky.internal.usecase.UpdatePinnedFeedsOrderUseCase import com.zhangke.fread.bluesky.internal.usecase.UpdatePreferencesUseCase import com.zhangke.fread.bluesky.internal.usecase.UpdateProfileRecordUseCase import com.zhangke.fread.bluesky.internal.usecase.UpdateRelationshipUseCase import com.zhangke.fread.bluesky.internal.usecase.UploadBlobUseCase import com.zhangke.fread.bluesky.internal.usecase.UploadImageByImageUrlUseCase import com.zhangke.fread.common.browser.BrowserInterceptor import com.zhangke.fread.status.IStatusProvider import com.zhangke.fread.status.model.PlatformLocator import org.koin.core.module.Module import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.bind import org.koin.dsl.module val blueskyModule = module { createPlatformModule() factoryOf(::BlueskyNavEntryProvider) bind NavEntryProvider::class singleOf(::BlueskyContentManager) singleOf(::BlueskyScreenProvider) singleOf(::BlueskyLoggedAccountRepo) singleOf(::BlueskyClientManager) singleOf(::BlueskyPlatformRepo) factoryOf(::BlueskyAccountManager) factoryOf(::BlueskyPublishManager) factoryOf(::BlueskySearchEngine) factoryOf(::BlueskyNotificationResolver) factoryOf(::BlueskyStatusResolver) factoryOf(::BlueskyStatusSourceResolver) factoryOf(::BskyStartup) factoryOf(::BlueskyContentMigrator) factoryOf(::BlueskyLoggedAccountManager) factoryOf(::BlueskyStatusAdapter) factoryOf(::BlueskyAccountAdapter) factoryOf(::BlueskyNotificationAdapter) factoryOf(::BlueskyFeedsAdapter) factoryOf(::BlueskyProfileAdapter) factoryOf(::UserUriTransformer) factoryOf(::PlatformUriTransformer) factoryOf(::LoginToBskyUseCase) factoryOf(::UpdateBlockUseCase) factoryOf(::UpdateHomeTabUseCase) factoryOf(::UpdatePinnedFeedsOrderUseCase) factoryOf(::UnpinFeedsUseCase) factoryOf(::UpdateRelationshipUseCase) factoryOf(::UpdatePreferencesUseCase) factoryOf(::GetAllListsUseCase) factoryOf(::GetFollowingFeedsUseCase) factoryOf(::GetFeedsStatusUseCase) factoryOf(::GetCompletedNotificationUseCase) factoryOf(::GetStatusContextUseCase) factoryOf(::GetAtIdentifierUseCase) factoryOf(::CreateRecordUseCase) factoryOf(::DeleteRecordUseCase) factoryOf(::PublishingPostUseCase) factoryOf(::UploadBlobUseCase) factoryOf(::UnblockUserWithoutUriUseCase) factoryOf(::BskyStatusInteractiveUseCase) factoryOf(::UpdateProfileRecordUseCase) factoryOf(::PinFeedsUseCase) factoryOf(::RefreshSessionUseCase) factoryOf(::UploadImageByImageUrlUseCase) viewModel { params -> AddBlueskyContentViewModel( loginToBluesky = get(), contentRepo = get(), platformRepo = get(), onboardingComponent = get(), baseUrl = params.values.getOrNull(0) as FormalBaseUrl?, loginMode = params.values[1] as Boolean, avatar = params.values.getOrNull(2) as String?, displayName = params.values.getOrNull(3) as String?, handle = params.values.getOrNull(4) as String?, ) } viewModelOf(::BlueskyContentContainerViewModel) viewModelOf(::HomeFeedsContainerViewModel) viewModel { params -> BskyFollowingFeedsViewModel( getFollowingFeeds = get(), contentRepo = get(), updatePinnedFeedsOrder = get(), accountManager = get(), contentId = params.getOrNull(), locator = params.getOrNull(), ) } viewModelOf(::ExplorerFeedsViewModel) viewModelOf(::FeedsDetailViewModel) viewModelOf(::SearchStatusViewModel) viewModelOf(::BskyUserDetailViewModel) viewModelOf(::EditProfileViewModel) viewModel { params -> UserListViewModel( clientManager = get(), accountAdapter = get(), updateRelationship = get(), updateBlock = get(), locator = params[0], type = params[1], postUri = params.values.getOrNull(2) as? String?, userDid = params.values.getOrNull(3) as? String?, ) } viewModel { params -> PublishPostViewModel( clientManager = get(), getAllLists = get(), platformUriHelper = get(), configManager = get(), publishingPost = get(), linkPreviewCardRepo = get(), locator = params[0], defaultText = params.values.getOrNull(1) as String?, replyBlogJsonString = params.values.getOrNull(2) as String?, quoteBlogJsonString = params.values.getOrNull(3) as String?, ) } factoryOf(::BlueskyProvider) bind IStatusProvider::class factoryOf(::BskyUrlInterceptor) bind BrowserInterceptor::class } expect fun Module.createPlatformModule() ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/BlueskyNavEntryProvider.kt ================================================ package com.zhangke.fread.bluesky import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.zhangke.framework.nav.NavEntryProvider import com.zhangke.fread.bluesky.internal.screen.add.AddBlueskyContentScreen import com.zhangke.fread.bluesky.internal.screen.add.AddBlueskyContentScreenNavKey import com.zhangke.fread.bluesky.internal.screen.feeds.explorer.ExplorerFeedsScreen import com.zhangke.fread.bluesky.internal.screen.feeds.explorer.ExplorerFeedsScreenNavKey import com.zhangke.fread.bluesky.internal.screen.feeds.following.BskyFollowingFeedsPage import com.zhangke.fread.bluesky.internal.screen.feeds.following.BskyFollowingFeedsPageNavKey import com.zhangke.fread.bluesky.internal.screen.feeds.home.HomeFeedsScreen import com.zhangke.fread.bluesky.internal.screen.feeds.home.HomeFeedsScreenNavKey import com.zhangke.fread.bluesky.internal.screen.publish.PublishPostScreen import com.zhangke.fread.bluesky.internal.screen.publish.PublishPostScreenNavKey import com.zhangke.fread.bluesky.internal.screen.search.SearchStatusScreen import com.zhangke.fread.bluesky.internal.screen.search.SearchStatusScreenNavKey import com.zhangke.fread.bluesky.internal.screen.user.detail.BskyUserDetailScreen import com.zhangke.fread.bluesky.internal.screen.user.detail.BskyUserDetailScreenNavKey import com.zhangke.fread.bluesky.internal.screen.user.edit.EditProfileScreen import com.zhangke.fread.bluesky.internal.screen.user.edit.EditProfileScreenNavKey import com.zhangke.fread.bluesky.internal.screen.user.list.UserListScreen import com.zhangke.fread.bluesky.internal.screen.user.list.UserListScreenNavKey import kotlinx.serialization.modules.PolymorphicModuleBuilder import kotlinx.serialization.modules.subclass import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parameterArrayOf import org.koin.core.parameter.parametersOf class BlueskyNavEntryProvider : NavEntryProvider { override fun EntryProviderScope.build() { entry { key -> BskyFollowingFeedsPage( koinViewModel { parametersOf(key.contentId, key.locator) } ) } entry { key -> HomeFeedsScreen( feedsJson = key.feedsJson, locator = key.locator, ) } entry { SearchStatusScreen(koinViewModel()) } entry { key -> AddBlueskyContentScreen( koinViewModel { parametersOf( key.baseUrl, key.loginMode, key.avatar, key.displayName, key.handle, ) } ) } entry { key -> PublishPostScreen( koinViewModel { parametersOf( key.locator, key.defaultText, key.replyToJsonString, key.quoteJsonString, ) } ) } entry { key -> UserListScreen( locator = key.locator, type = key.type, viewModel = koinViewModel { parameterArrayOf( key.locator, key.type, key.postUri, key.did, ) }, ) } entry { key -> BskyUserDetailScreen( locator = key.locator, did = key.did, viewModel = koinViewModel { parametersOf(key.locator, key.did) }, ) } entry { key -> EditProfileScreen( koinViewModel { parametersOf(key.locator) } ) } entry { key -> ExplorerFeedsScreen( locator = key.locator, inlineMode = false, ) } } override fun PolymorphicModuleBuilder.polymorph() { subclass(BskyFollowingFeedsPageNavKey::class) subclass(HomeFeedsScreenNavKey::class) subclass(SearchStatusScreenNavKey::class) subclass(AddBlueskyContentScreenNavKey::class) subclass(PublishPostScreenNavKey::class) subclass(UserListScreenNavKey::class) subclass(BskyUserDetailScreenNavKey::class) subclass(EditProfileScreenNavKey::class) subclass(ExplorerFeedsScreenNavKey::class) } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/BlueskyNotificationResolver.kt ================================================ package com.zhangke.fread.bluesky import app.bsky.actor.GetProfilesQueryParams import app.bsky.notification.ListNotificationsQueryParams import app.bsky.notification.UpdateSeenRequest import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccount import com.zhangke.fread.bluesky.internal.adapter.BlueskyAccountAdapter import com.zhangke.fread.bluesky.internal.adapter.BlueskyNotificationAdapter import com.zhangke.fread.bluesky.internal.client.BlueskyClientManager import com.zhangke.fread.bluesky.internal.usecase.GetCompletedNotificationUseCase import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.notBluesky import com.zhangke.fread.status.notification.INotificationResolver import com.zhangke.fread.status.notification.PagedStatusNotification import sh.christian.ozone.api.Did import kotlinx.datetime.Clock import kotlin.time.ExperimentalTime class BlueskyNotificationResolver( private val clientManager: BlueskyClientManager, private val getCompletedNotification: GetCompletedNotificationUseCase, private val notificationAdapter: BlueskyNotificationAdapter, private val accountAdapter: BlueskyAccountAdapter, ) : INotificationResolver { override suspend fun getNotifications( account: LoggedAccount, type: INotificationResolver.NotificationRequestType, cursor: String?, ): Result? { if (account !is BlueskyLoggedAccount) return null return getCompletedNotification( locator = account.locator, params = ListNotificationsQueryParams( reasons = if (type == INotificationResolver.NotificationRequestType.MENTION) { listOf("mention", "reply", "quote") } else { emptyList() }, cursor = cursor, limit = 25, ), ).map { paged -> PagedStatusNotification( cursor = paged.cursor, reachEnd = paged.cursor == null, notifications = paged.notifications.map { notificationAdapter.convert(it, account.locator, account.platform) }, ) } } override suspend fun getNotificationUserDetail( account: LoggedAccount, users: List, ): Result>? { if (account !is BlueskyLoggedAccount) return null val didList = users.filter { it.banner.isNullOrEmpty() } .mapNotNull { it.webFinger.did } .map { Did(it) } if (didList.isEmpty()) return Result.success(emptyList()) return clientManager.getClient(account.locator) .getProfilesCatching(GetProfilesQueryParams(actors = didList.take(25))) .map { result -> result.profiles.map { profile -> accountAdapter.convertToBlogAuthor( did = profile.did.did, handle = profile.handle.handle, profileViewDetailed = profile, ) } } } override suspend fun rejectFollowRequest( account: LoggedAccount, requestAuthor: BlogAuthor ): Result? { return null } override suspend fun acceptFollowRequest( account: LoggedAccount, requestAuthor: BlogAuthor ): Result? { return null } @OptIn(ExperimentalTime::class) override suspend fun updateUnreadNotification( account: LoggedAccount, notificationLastReadId: String ): Result? { if (account.platform.protocol.notBluesky) return null if (account !is BlueskyLoggedAccount) return null val client = clientManager.getClient(account.locator) return client.updateSeenCatching(UpdateSeenRequest(Clock.System.now())) .map { } } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/BlueskyProvider.kt ================================================ package com.zhangke.fread.bluesky import com.zhangke.fread.bluesky.internal.content.BlueskyContentManager import com.zhangke.fread.status.IStatusProvider import com.zhangke.fread.status.account.IAccountManager import com.zhangke.fread.status.content.IContentManager import com.zhangke.fread.status.notification.INotificationResolver import com.zhangke.fread.status.platform.IPlatformResolver import com.zhangke.fread.status.publish.IPublishBlogManager import com.zhangke.fread.status.screen.IStatusScreenProvider import com.zhangke.fread.status.search.ISearchEngine import com.zhangke.fread.status.source.IStatusSourceResolver import com.zhangke.fread.status.status.IStatusResolver class BlueskyProvider( blueskyContentManager: BlueskyContentManager, screenProvider: BlueskyScreenProvider, searchEngine: BlueskySearchEngine, statusResolver: BlueskyStatusResolver, statusSourceResolver: BlueskyStatusSourceResolver, accountManager: BlueskyAccountManager, notificationResolver: BlueskyNotificationResolver, blueskyPublishManager: BlueskyPublishManager, ) : IStatusProvider { override val contentManager: IContentManager = blueskyContentManager override val screenProvider: IStatusScreenProvider = screenProvider override val searchEngine: ISearchEngine = searchEngine override val statusResolver: IStatusResolver = statusResolver override val statusSourceResolver: IStatusSourceResolver = statusSourceResolver override val accountManager: IAccountManager = accountManager override val notificationResolver: INotificationResolver = notificationResolver override val publishManager: IPublishBlogManager = blueskyPublishManager } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/BlueskyPublishManager.kt ================================================ package com.zhangke.fread.bluesky import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccount import com.zhangke.fread.bluesky.internal.screen.publish.PublishPostMediaAttachment import com.zhangke.fread.bluesky.internal.screen.publish.PublishPostMediaAttachmentFile import com.zhangke.fread.bluesky.internal.usecase.PublishingPostUseCase import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.PublishBlogRules import com.zhangke.fread.status.model.notBluesky import com.zhangke.fread.status.publish.IPublishBlogManager import com.zhangke.fread.status.publish.PublishingMedia import com.zhangke.fread.status.publish.PublishingPost class BlueskyPublishManager ( private val publishingPost: PublishingPostUseCase, ) : IPublishBlogManager { override suspend fun getPublishBlogRules(account: LoggedAccount): Result? { if (account.platform.protocol.notBluesky) return null return Result.success( PublishBlogRules( maxCharacters = 300, maxMediaCount = 4, maxPollOptions = 0, supportSpoiler = false, supportPoll = false, maxLanguageCount = 2, mediaAltMaxCharacters = 2000, ) ) } override suspend fun publish( account: LoggedAccount, post: PublishingPost ): Result? { if (account.platform.protocol.notBluesky) return null val bskyAccount = account as BlueskyLoggedAccount return publishingPost( account = bskyAccount, content = post.content, interactionSetting = post.interactionSetting, selectedLanguages = listOf(post.languageCode), attachment = post.medias.convert(), ) } private fun List.convert(): PublishPostMediaAttachment? { if (this.isEmpty()) return null if (this.first().isVideo) { return PublishPostMediaAttachment.Video(this.first().convert()) } return PublishPostMediaAttachment.Image(this.map { it.convert() }) } private fun PublishingMedia.convert(): PublishPostMediaAttachmentFile { return PublishPostMediaAttachmentFile( file = this.file, alt = this.alt, isVideo = this.isVideo, ) } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/BlueskyScreenProvider.kt ================================================ package com.zhangke.fread.bluesky import androidx.navigation3.runtime.NavKey import com.zhangke.framework.architect.json.globalJson import com.zhangke.framework.nav.Tab import com.zhangke.framework.utils.WebFinger import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccount import com.zhangke.fread.bluesky.internal.content.BlueskyContent import com.zhangke.fread.bluesky.internal.model.BlueskyFeeds import com.zhangke.fread.bluesky.internal.screen.add.AddBlueskyContentScreenNavKey import com.zhangke.fread.bluesky.internal.screen.explorer.BlueskyExplorerTab import com.zhangke.fread.bluesky.internal.screen.feeds.following.BskyFollowingFeedsPageNavKey import com.zhangke.fread.bluesky.internal.screen.feeds.home.HomeFeedsScreenNavKey import com.zhangke.fread.bluesky.internal.screen.content.BlueskyContentTab import com.zhangke.fread.bluesky.internal.screen.publish.PublishPostScreenNavKey import com.zhangke.fread.bluesky.internal.screen.user.detail.BskyUserDetailScreenNavKey import com.zhangke.fread.bluesky.internal.screen.user.list.UserListScreenNavKey import com.zhangke.fread.bluesky.internal.screen.user.list.UserListType import com.zhangke.fread.bluesky.internal.uri.user.UserUriTransformer import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.model.FreadContent import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusProviderProtocol import com.zhangke.fread.status.model.notBluesky import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.screen.IStatusScreenProvider import com.zhangke.fread.status.uri.FormalUri class BlueskyScreenProvider( private val userUriTransformer: UserUriTransformer, ) : IStatusScreenProvider { override fun getReplyBlogScreen( locator: PlatformLocator, blog: Blog, ): NavKey? { if (blog.platform.protocol.notBluesky) return null return PublishPostScreenNavKey( locator = locator, replyToJsonString = globalJson.encodeToString(blog) ) } override fun getEditBlogScreen( locator: PlatformLocator, blog: Blog ): NavKey? { return null } override fun getQuoteBlogScreen(locator: PlatformLocator, blog: Blog): NavKey? { if (blog.platform.protocol.notBluesky) return null return PublishPostScreenNavKey( locator = locator, quoteJsonString = globalJson.encodeToString(blog) ) } override fun getContentScreen( content: FreadContent, isLatestTab: Boolean ): Tab { return BlueskyContentTab(content.id, isLatestTab) } override fun getEditContentConfigScreenScreen(content: FreadContent): NavKey? { if (content !is BlueskyContent) return null return BskyFollowingFeedsPageNavKey(contentId = content.id, locator = null) } override fun getUserDetailScreen( locator: PlatformLocator, uri: FormalUri, userId: String?, ): NavKey? { val did = userUriTransformer.parse(uri)?.did ?: return null return BskyUserDetailScreenNavKey(locator = locator, did = did) } override fun getUserDetailScreen( locator: PlatformLocator, did: String, protocol: StatusProviderProtocol ): NavKey? { if (protocol.notBluesky) return null return BskyUserDetailScreenNavKey(locator = locator, did = did) } override fun getUserDetailScreen( locator: PlatformLocator, webFinger: WebFinger, protocol: StatusProviderProtocol ): NavKey? { if (protocol.notBluesky) return null return BskyUserDetailScreenNavKey(locator, webFinger.did ?: return null) } override fun getTagTimelineScreen( locator: PlatformLocator, tag: String, protocol: StatusProviderProtocol ): NavKey? { if (protocol.notBluesky) return null return HomeFeedsScreenNavKey.create( feeds = BlueskyFeeds.Hashtags(tag), locator = locator, ) } override fun getBlogFavouritedScreen( locator: PlatformLocator, blog: Blog, protocol: StatusProviderProtocol ): NavKey? { if (protocol.notBluesky) return null return UserListScreenNavKey( locator = locator, type = UserListType.LIKE, postUri = blog.url, ) } override fun getBlogBoostedScreen( locator: PlatformLocator, blog: Blog, protocol: StatusProviderProtocol ): NavKey? { if (protocol.notBluesky) return null return UserListScreenNavKey( locator = locator, type = UserListType.REBLOG, postUri = blog.url, ) } override fun getExplorerTab(locator: PlatformLocator, platform: BlogPlatform): Tab? { if (platform.protocol.notBluesky) return null return BlueskyExplorerTab(locator) } override fun getAddContentScreen(protocol: StatusProviderProtocol): NavKey? { if (protocol.notBluesky) return null return AddBlueskyContentScreenNavKey() } override fun getPublishScreen(account: LoggedAccount, text: String): NavKey? { if (account !is BlueskyLoggedAccount) return null return PublishPostScreenNavKey( locator = account.locator, defaultText = text, ) } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/BlueskySearchEngine.kt ================================================ package com.zhangke.fread.bluesky import app.bsky.actor.GetProfileQueryParams import app.bsky.actor.SearchActorsQueryParams import app.bsky.feed.SearchPostsQueryParams import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccountManager import com.zhangke.fread.bluesky.internal.adapter.BlueskyAccountAdapter import com.zhangke.fread.bluesky.internal.adapter.BlueskyStatusAdapter import com.zhangke.fread.bluesky.internal.client.BlueskyClientManager import com.zhangke.fread.bluesky.internal.repo.BlueskyPlatformRepo import com.zhangke.fread.bluesky.internal.usecase.GetAtIdentifierUseCase import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.Hashtag import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.search.ISearchEngine import com.zhangke.fread.status.search.SearchContentResult import com.zhangke.fread.status.search.SearchResult import com.zhangke.fread.status.source.StatusSource import kotlinx.coroutines.async import kotlinx.coroutines.supervisorScope class BlueskySearchEngine( private val clientManager: BlueskyClientManager, private val accountAdapter: BlueskyAccountAdapter, private val getAtIdentifier: GetAtIdentifierUseCase, private val blueskyPlatformRepo: BlueskyPlatformRepo, private val statusAdapter: BlueskyStatusAdapter, private val platformRepo: BlueskyPlatformRepo, private val accountManager: BlueskyLoggedAccountManager, ) : ISearchEngine { override suspend fun search( locator: PlatformLocator, query: String, ): Result> { return supervisorScope { val postsDeferred = async { searchStatus(locator, query, null) } val actorsDeferred = async { searchAuthor(locator, query, null) } val postsResult = postsDeferred.await() val actorResult = actorsDeferred.await() if (postsResult.isFailure && actorResult.isFailure) { Result.failure( postsResult.exceptionOrNull() ?: actorResult.exceptionOrNull()!! ) } else { val status: List = postsResult.getOrNull() ?.map { SearchResult.SearchedStatus(it) } ?: emptyList() val actors: List = actorResult.getOrNull() ?.map { actor -> SearchResult.Author(actor) } ?: emptyList() Result.success(actors + status) } } } override suspend fun searchStatus( locator: PlatformLocator, query: String, maxId: String?, ): Result> { val client = clientManager.getClient(locator) val account = client.loggedAccountProvider() val platform = platformRepo.getPlatform(client.baseUrl) return client.searchPostsCatching(SearchPostsQueryParams(q = query)) .map { result -> result.posts.map { statusAdapter.convertToUiState( locator = locator, postView = it, platform = platform, loggedAccount = account, ) } } } override suspend fun searchHashtag( locator: PlatformLocator, query: String, offset: Int?, ): Result> { return Result.success(emptyList()) } override suspend fun searchAuthor( locator: PlatformLocator, query: String, offset: Int?, ): Result> { val client = clientManager.getClient(locator) return client.searchActorsCatching(SearchActorsQueryParams(q = query)) .map { result -> result.actors.map { accountAdapter.convertToBlogAuthor(it) } } } override suspend fun searchSourceNoToken(query: String): Result> { val client = clientManager.getClient(getDefaultPlatformLocator()) val identifier = getAtIdentifier(query) ?: return client.searchActorsCatching(SearchActorsQueryParams(q = query, limit = 10)) .map { result -> result.actors.map { accountAdapter.createSource(it) } } return client.getProfileCatching(GetProfileQueryParams(identifier)) .map { profile -> listOf(accountAdapter.createSource(profile)) } } private suspend fun getDefaultPlatformLocator(): PlatformLocator { val baseUrl = accountManager.getAllAccount().firstOrNull()?.platform?.baseUrl ?: blueskyPlatformRepo.getAllPlatform().first().baseUrl return PlatformLocator(baseUrl = baseUrl) } private fun BlogPlatform.compareWithQuery(query: String): Boolean { if (this.name.contains(query)) return true if (this.baseUrl.toString().contains(query)) return true return uri.contains(query) } private fun BlogPlatform.toContentResult(): SearchContentResult { return SearchContentResult.Platform(this) } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/BlueskyStatusResolver.kt ================================================ package com.zhangke.fread.bluesky import app.bsky.actor.GetProfileQueryParams import app.bsky.feed.GetAuthorFeedFilter import app.bsky.feed.GetAuthorFeedQueryParams import app.bsky.feed.GetPostsQueryParams import com.zhangke.fread.bluesky.internal.adapter.BlueskyStatusAdapter import com.zhangke.fread.bluesky.internal.client.BlueskyClientManager import com.zhangke.fread.bluesky.internal.repo.BlueskyPlatformRepo import com.zhangke.fread.bluesky.internal.uri.user.UserUriTransformer import com.zhangke.fread.bluesky.internal.usecase.BskyStatusInteractiveUseCase import com.zhangke.fread.bluesky.internal.usecase.GetStatusContextUseCase import com.zhangke.fread.bluesky.internal.usecase.UpdateRelationshipType import com.zhangke.fread.bluesky.internal.usecase.UpdateRelationshipUseCase import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.blog.BlogPoll import com.zhangke.fread.status.blog.BlogTranslation import com.zhangke.fread.status.model.PagedData import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusActionType import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.model.notBluesky import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.status.IStatusResolver import com.zhangke.fread.status.status.model.Status import com.zhangke.fread.status.status.model.StatusContext import com.zhangke.fread.status.uri.FormalUri import sh.christian.ozone.api.AtUri import sh.christian.ozone.api.Did class BlueskyStatusResolver( private val clientManager: BlueskyClientManager, private val statusAdapter: BlueskyStatusAdapter, private val platformRepo: BlueskyPlatformRepo, private val uriTransformer: UserUriTransformer, private val updateRelationship: UpdateRelationshipUseCase, private val statusInteractive: BskyStatusInteractiveUseCase, private val getStatusContextFunction: GetStatusContextUseCase, private val userUriTransformer: UserUriTransformer, ) : IStatusResolver { override suspend fun getStatus( locator: PlatformLocator, blogId: String?, blogUri: String?, platform: BlogPlatform ): Result? { if (platform.protocol.notBluesky) return null val client = clientManager.getClient(locator) val account = client.loggedAccountProvider() if (blogUri.isNullOrEmpty()) return Result.failure(IllegalArgumentException("blogUri is null!")) return client.getPostsCatching(GetPostsQueryParams(listOf(AtUri(blogUri)))) .map { statusAdapter.convertToUiState( locator = locator, postView = it.posts.first(), platform = platform, loggedAccount = account, ) } } override suspend fun getStatusList( uri: FormalUri, limit: Int, maxId: String?, ): Result>? { val uriInsight = uriTransformer.parse(uri) ?: return null val platform = platformRepo.getAllPlatform().first() val locator = PlatformLocator(baseUrl = platform.baseUrl) val client = clientManager.getClient(locator) val account = client.loggedAccountProvider() return client.getAuthorFeedCatching( GetAuthorFeedQueryParams( actor = Did(uriInsight.did), filter = GetAuthorFeedFilter.PostsAndAuthorThreads, includePins = true, limit = 80, ) ).map { result -> PagedData( list = result.feed.map { statusAdapter.convertToUiState( locator = locator, feedViewPost = it, platform = platform, loggedAccount = account, ) }, cursor = result.cursor, ) } } override suspend fun interactive( locator: PlatformLocator, status: Status, type: StatusActionType, ): Result? { if (status.platform.protocol.notBluesky) return null return statusInteractive(locator, status, type) } override suspend fun votePoll( locator: PlatformLocator, blog: Blog, votedOption: List ): Result? { return null } override suspend fun getStatusContext( locator: PlatformLocator, status: Status ): Result? { if (status.platform.protocol.notBluesky) return null return getStatusContextFunction(locator, status) } override suspend fun follow( locator: PlatformLocator, target: BlogAuthor ): Result? { val userUriInsights = userUriTransformer.parse(target.uri) ?: return null return updateRelationship( locator = locator, targetDid = userUriInsights.did, type = UpdateRelationshipType.FOLLOW, ).map { } } override suspend fun unfollow( locator: PlatformLocator, target: BlogAuthor ): Result? { val userUriInsights = userUriTransformer.parse(target.uri) ?: return null return updateRelationship( locator = locator, targetDid = userUriInsights.did, type = UpdateRelationshipType.UNFOLLOW, ).map { } } override suspend fun isFollowing( locator: PlatformLocator, target: BlogAuthor ): Result? { val did = userUriTransformer.parse(target.uri)?.did ?: return null val client = clientManager.getClient(locator) val profileResult = client.getProfileCatching(GetProfileQueryParams(Did(did))) if (profileResult.isFailure) return Result.failure(profileResult.exceptionOrNull()!!) val profile = profileResult.getOrThrow() return Result.success(profile.viewer?.following?.atUri.isNullOrEmpty().not()) } override suspend fun translate( locator: PlatformLocator, status: Status, lan: String ): Result? { if (status.platform.protocol.notBluesky) return null return Result.failure(RuntimeException("Not implemented")) } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/BlueskyStatusSourceResolver.kt ================================================ package com.zhangke.fread.bluesky import app.bsky.actor.GetProfileQueryParams import com.zhangke.fread.bluesky.internal.adapter.BlueskyAccountAdapter import com.zhangke.fread.bluesky.internal.client.BlueskyClientManager import com.zhangke.fread.bluesky.internal.repo.BlueskyPlatformRepo import com.zhangke.fread.bluesky.internal.uri.user.UserUriTransformer import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.source.IStatusSourceResolver import com.zhangke.fread.status.source.StatusSource import com.zhangke.fread.status.uri.FormalUri import sh.christian.ozone.api.Did class BlueskyStatusSourceResolver( private val clientManager: BlueskyClientManager, private val platformRepo: BlueskyPlatformRepo, private val accountAdapter: BlueskyAccountAdapter, private val userUriTransformer: UserUriTransformer, ) : IStatusSourceResolver { override suspend fun resolveSourceByUri(uri: FormalUri): Result { val uriInsight = userUriTransformer.parse(uri) ?: return Result.success(null) val locator = PlatformLocator(baseUrl = platformRepo.getAllPlatform().first().baseUrl) val client = clientManager.getClient(locator) return client.getProfileCatching(GetProfileQueryParams(Did(uriInsight.did))) .map { profile -> accountAdapter.createSource(profile) } } override suspend fun resolveRssSource(rssUrl: String): Result? { return null } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/BskyStartup.kt ================================================ package com.zhangke.fread.bluesky import com.zhangke.framework.architect.coroutines.ApplicationScope import com.zhangke.framework.module.ModuleStartup import com.zhangke.fread.bluesky.internal.migrate.BlueskyContentMigrator import com.zhangke.fread.bluesky.internal.usecase.RefreshSessionUseCase import kotlinx.coroutines.launch class BskyStartup( private val refreshSession: RefreshSessionUseCase, private val contentMigrator: BlueskyContentMigrator, ) : ModuleStartup { override fun onAppCreate() { ApplicationScope.launch { contentMigrator.migrate() refreshSession() } } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/BskyUrlInterceptor.kt ================================================ package com.zhangke.fread.bluesky import androidx.navigation3.runtime.NavKey import app.bsky.actor.GetProfileQueryParams import app.bsky.feed.GetPostThreadQueryParams import app.bsky.feed.GetPostThreadResponseThreadUnion import com.atproto.repo.GetRecordQueryParams import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.network.HttpScheme import com.zhangke.framework.network.SimpleUri import com.zhangke.framework.network.addProtocolSuffixIfNecessary import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccount import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccountManager import com.zhangke.fread.bluesky.internal.adapter.BlueskyStatusAdapter import com.zhangke.fread.bluesky.internal.client.BlueskyClientManager import com.zhangke.fread.bluesky.internal.client.BskyCollections import com.zhangke.fread.bluesky.internal.content.BlueskyContent import com.zhangke.fread.bluesky.internal.repo.BlueskyPlatformRepo import com.zhangke.fread.bluesky.internal.screen.user.detail.BskyUserDetailScreenNavKey import com.zhangke.fread.common.browser.BrowserInterceptor import com.zhangke.fread.common.browser.InterceptorResult import com.zhangke.fread.common.content.FreadContentRepo import com.zhangke.fread.commonbiz.shared.screen.status.context.StatusContextScreenNavKey import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.createBlueskyProtocol import sh.christian.ozone.api.Handle import sh.christian.ozone.api.RKey class BskyUrlInterceptor( private val accountManager: BlueskyLoggedAccountManager, private val clientManager: BlueskyClientManager, private val statusAdapter: BlueskyStatusAdapter, private val platformRepo: BlueskyPlatformRepo, private val contentRepo: FreadContentRepo, ) : BrowserInterceptor { override suspend fun intercept( locator: PlatformLocator?, url: String, isFromExternal: Boolean, ): InterceptorResult { var uri = SimpleUri.parse(url) ?: return InterceptorResult.CanNotIntercept if (!HttpScheme.validate(uri.scheme.orEmpty().addProtocolSuffixIfNecessary())) { return InterceptorResult.CanNotIntercept } if (uri.host.isNullOrEmpty()) return InterceptorResult.CanNotIntercept val isProfileUrl = isProfileUrl(uri) val isPostUrl = isPostUrl(uri) if (!isProfileUrl && !isPostUrl) { if (isFromExternal) { if (platformRepo.appViewDomains.any { uri.host == it }) { val content = contentRepo.getAllContent() .firstNotNullOfOrNull { it as? BlueskyContent } if (content != null) { return InterceptorResult.SwitchHomeContent(content) } } } return InterceptorResult.CanNotIntercept } uri = uri.copy(host = platformRepo.mapAppToBackendDomain(uri.host!!)) val baseUrl = locator?.baseUrl ?: FormalBaseUrl.parse(uri.host!!) ?: return InterceptorResult.CanNotIntercept var account: BlueskyLoggedAccount? = null val fixedLocator = if (locator?.accountUri != null) { locator } else { val accounts = accountManager.getAllAccount() .filter { it.fromPlatform.baseUrl == baseUrl } if (accounts.isEmpty()) { locator ?: PlatformLocator(baseUrl) } else if (accounts.size == 1) { account = accounts.first() PlatformLocator(account.platform.baseUrl, account.uri) } else { return InterceptorResult.RequireSelectAccount(createBlueskyProtocol()) } } parseProfile(fixedLocator, uri)?.let { return InterceptorResult.SuccessWithOpenNewScreen(it) } parsePost(fixedLocator, uri, account)?.let { return InterceptorResult.SuccessWithOpenNewScreen(it) } return InterceptorResult.CanNotIntercept } private fun isProfileUrl(uri: SimpleUri): Boolean { val path = uri.path if (path.isNullOrEmpty()) return false if (!path.startsWith("/profile/")) return false val handle = path.split('/').lastOrNull() if (handle.isNullOrEmpty()) return false if (!handle.contains('.')) return false return true } private suspend fun parseProfile(locator: PlatformLocator, uri: SimpleUri): NavKey? { if (!isProfileUrl(uri)) return null val handle = uri.path?.split('/')?.lastOrNull() if (handle.isNullOrEmpty()) return null val profile = clientManager.getClient(locator) .getProfileCatching(GetProfileQueryParams(Handle(handle))) .getOrNull() if (profile == null) return null return BskyUserDetailScreenNavKey(locator = locator, did = profile.did.did) } private fun isPostUrl(uri: SimpleUri): Boolean { val path = uri.path if (path.isNullOrEmpty()) return false val groupedPath = path.removePrefix("/").removeSuffix("/").split('/') if (groupedPath.size != 4) return false if (groupedPath[0] != "profile") return false if (groupedPath[2] != "post") return false return true } private suspend fun parsePost( locator: PlatformLocator, uri: SimpleUri, account: BlueskyLoggedAccount? ): NavKey? { if (!isPostUrl(uri)) return null val groupedPath = uri.path ?.removePrefix("/") ?.removeSuffix("/") ?.split('/') ?: return null val client = clientManager.getClient(locator) val statusUiState = client.getRecordCatching( GetRecordQueryParams( repo = Handle(groupedPath[1]), collection = BskyCollections.feedPost, rkey = RKey(groupedPath[3]), ) ).mapCatching { value -> client.getPostThreadCatching(GetPostThreadQueryParams(uri = value.uri)) .map { (it.thread as? GetPostThreadResponseThreadUnion.ThreadViewPost)?.value?.post } .getOrNull() }.mapCatching { postView -> if (postView != null) { statusAdapter.convertToUiState( postView = postView, locator = locator, platform = platformRepo.getPlatform(locator.baseUrl), loggedAccount = account, pinned = false, ) } else { null } }.getOrNull() ?: return null return StatusContextScreenNavKey.create(statusUiState) } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/account/BlueskyLoggedAccount.kt ================================================ package com.zhangke.fread.bluesky.internal.account import com.zhangke.framework.datetime.Instant import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.platform.BlogPlatform import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject @Serializable data class BlueskyLoggedAccount( val user: BlogAuthor, val fromPlatform: BlogPlatform, val did: String, val didDoc: JsonObject, val handle: String, val email: String?, val emailConfirmed: Boolean?, val emailAuthFactor: Boolean?, val accessJwt: String, val refreshJwt: String, val active: Boolean?, val createAt: Instant? = null, ) : LoggedAccount { override val uri = user.uri override val webFinger = user.webFinger override val platform = fromPlatform override val id: String = did override val userName = user.name override val description = user.description override val avatar = user.avatar override val emojis = user.emojis override val prettyHandle: String = user.prettyHandle } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/account/BlueskyLoggedAccountManager.kt ================================================ package com.zhangke.fread.bluesky.internal.account import app.bsky.actor.GetProfileQueryParams import app.bsky.actor.ProfileViewDetailed import com.atproto.server.CreateSessionRequest import com.atproto.server.CreateSessionResponse import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.utils.exceptionOrThrow import com.zhangke.fread.bluesky.internal.adapter.BlueskyAccountAdapter import com.zhangke.fread.bluesky.internal.client.BlueskyClient import com.zhangke.fread.bluesky.internal.client.BlueskyClientManager import com.zhangke.fread.bluesky.internal.repo.BlueskyLoggedAccountRepo import com.zhangke.fread.bluesky.internal.repo.BlueskyPlatformRepo import com.zhangke.fread.status.account.AccountRefreshResult import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.uri.FormalUri import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.mapNotNull import sh.christian.ozone.api.Did class BlueskyLoggedAccountManager( private val clientManager: BlueskyClientManager, private val accountAdapter: BlueskyAccountAdapter, private val platformRepo: BlueskyPlatformRepo, private val accountRepo: BlueskyLoggedAccountRepo, ) { suspend fun login( baseUrl: FormalBaseUrl, identifier: String, password: String, factorToken: String? = null, ): Result { val noAccountClient = clientManager.getClientNoAccount(baseUrl) val sessionResult = noAccountClient.createSessionCatching( CreateSessionRequest( identifier = identifier, password = password, authFactorToken = factorToken, ) ) if (sessionResult.isFailure) { return Result.failure(sessionResult.exceptionOrThrow()) } val session = sessionResult.getOrThrow() val platform = platformRepo.getPlatform(baseUrl) val account = saveAccountToLocal(session, null, platform) val locator = PlatformLocator(baseUrl = baseUrl, accountUri = account.uri) val profileResult = clientManager.getClient(locator).getProfile(session.did.did) if (profileResult.isFailure) { return Result.failure(profileResult.exceptionOrThrow()) } return Result.success(saveAccountToLocal(session, profileResult.getOrThrow(), platform)) } private suspend fun saveAccountToLocal( session: CreateSessionResponse, profile: ProfileViewDetailed?, platform: BlogPlatform, ): BlueskyLoggedAccount { val loggedAccount = accountAdapter.createBlueskyAccount( profileViewDetailed = profile, createSessionResponse = session, platform = platform, ) accountRepo.insert(loggedAccount) return loggedAccount } suspend fun logout(uri: FormalUri) { accountRepo.deleteByUri(uri.toString()) } suspend fun getAllAccount(): List { return accountRepo.queryAll() } fun getAllAccountFlow(): Flow> { return accountRepo.queryAllFlow() } fun getAccountFlow(uri: FormalUri): Flow { return getAllAccountFlow().mapNotNull { it.firstOrNull { it.uri == uri } } } suspend fun getAccount(locator: PlatformLocator): BlueskyLoggedAccount? { val allAccount = getAllAccount() if (locator.accountUri != null) { allAccount.firstOrNull { it.uri == locator.accountUri }?.let { return it } } return allAccount.firstOrNull() } suspend fun updateAccountProfile( locator: PlatformLocator, profile: ProfileViewDetailed, ) { val account = getAccount(locator) ?: return if (account.did != profile.did.did) return val newAccount = accountAdapter.updateProfile(account, profile) accountRepo.updateAccount(account, newAccount) } suspend fun refreshAccountProfile(): List { return accountRepo.queryAll().map { account -> val locator = PlatformLocator(accountUri = account.uri, baseUrl = account.platform.baseUrl) val result = clientManager.getClient(locator = locator).getProfile(account.did) if (result.isFailure) { AccountRefreshResult.Failure(account, result.exceptionOrThrow()) } else { val newAccount = accountAdapter.updateProfile(account, result.getOrThrow()) accountRepo.updateAccount(account, newAccount) AccountRefreshResult.Success(newAccount) } } } private suspend fun BlueskyClient.getProfile(did: String): Result { return this.getProfileCatching(GetProfileQueryParams(Did(did))) } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/adapter/BlueskyAccountAdapter.kt ================================================ package com.zhangke.fread.bluesky.internal.adapter import app.bsky.actor.ProfileView import app.bsky.actor.ProfileViewBasic import app.bsky.actor.ProfileViewDetailed import app.bsky.actor.ViewerState import com.atproto.server.CreateSessionResponse import com.atproto.server.RefreshSessionResponse import com.zhangke.framework.architect.json.Empty import com.zhangke.framework.datetime.Instant import com.zhangke.framework.utils.WebFinger import com.zhangke.framework.utils.prettyHandle import com.zhangke.fread.status.model.createBlueskyProtocol import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccount import com.zhangke.fread.bluesky.internal.uri.user.UserUriTransformer import com.zhangke.fread.bluesky.internal.utils.bskyJson import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.model.Relationships import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.source.StatusSource import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject import sh.christian.ozone.api.model.JsonContent import kotlin.time.ExperimentalTime class BlueskyAccountAdapter( private val userUriTransformer: UserUriTransformer, ) { @OptIn(ExperimentalTime::class) fun createBlueskyAccount( profileViewDetailed: ProfileViewDetailed?, createSessionResponse: CreateSessionResponse, platform: BlogPlatform, ): BlueskyLoggedAccount { val did = createSessionResponse.did.did val author = convertToBlogAuthor(did, createSessionResponse.handle.handle, profileViewDetailed) return BlueskyLoggedAccount( user = author, fromPlatform = platform, did = did, didDoc = createSessionResponse.didDoc?.tryToJsonObject() ?: JsonObject.Empty, handle = createSessionResponse.handle.handle, email = createSessionResponse.email, emailConfirmed = createSessionResponse.emailConfirmed, emailAuthFactor = createSessionResponse.emailAuthFactor, accessJwt = createSessionResponse.accessJwt, refreshJwt = createSessionResponse.refreshJwt, active = createSessionResponse.active, createAt = profileViewDetailed?.createdAt?.let { Instant(it) }, ) } fun convertToBlogAuthor( did: String, handle: String, profileViewDetailed: ProfileViewDetailed?, ): BlogAuthor { return BlogAuthor( uri = userUriTransformer.createUserUri(did), webFinger = WebFinger.createFromDid(did), handle = handle, name = profileViewDetailed?.displayName.orEmpty(), avatar = profileViewDetailed?.avatar?.uri, banner = profileViewDetailed?.banner?.uri, description = profileViewDetailed?.description.orEmpty(), emojis = emptyList(), followersCount = profileViewDetailed?.followersCount, followingCount = profileViewDetailed?.followsCount, statusesCount = profileViewDetailed?.postsCount, relationships = profileViewDetailed?.viewer?.let(::convertRelationship) ) } fun convertToBlogAuthor(profile: ProfileViewBasic): BlogAuthor { val did = profile.did.did return BlogAuthor( uri = userUriTransformer.createUserUri(did), webFinger = WebFinger.createFromDid(did), handle = profile.handle.handle, name = profile.displayName.orEmpty(), description = "", avatar = profile.avatar?.uri, banner = null, emojis = emptyList(), followersCount = null, followingCount = null, statusesCount = null, relationships = profile.viewer?.let(::convertRelationship) ) } fun convertToBlogAuthor( profile: ProfileView ): BlogAuthor { return BlogAuthor( uri = userUriTransformer.createUserUri(profile.did.did), webFinger = WebFinger.createFromDid(profile.did.did), handle = profile.handle.handle, name = profile.displayName.orEmpty(), avatar = profile.avatar?.uri, banner = null, description = profile.description.orEmpty(), emojis = emptyList(), followersCount = null, followingCount = null, statusesCount = null, relationships = profile.viewer?.let(::convertRelationship) ) } fun createSource( profile: ProfileViewDetailed, ): StatusSource { return StatusSource( uri = userUriTransformer.createUserUri(profile.did.did), name = profile.displayName.orEmpty(), handle = profile.handle.handle.prettyHandle(), description = profile.description.orEmpty(), protocol = createBlueskyProtocol(), thumbnail = profile.avatar?.uri, ) } fun createSource( profile: ProfileView, ): StatusSource { return StatusSource( uri = userUriTransformer.createUserUri(profile.did.did), name = profile.displayName.orEmpty(), handle = profile.handle.handle.prettyHandle(), description = profile.description.orEmpty(), protocol = createBlueskyProtocol(), thumbnail = profile.avatar?.uri, ) } fun updateNewSession( account: BlueskyLoggedAccount, session: RefreshSessionResponse, ): BlueskyLoggedAccount { val newDid = session.did.did return account.copy( user = account.user.copy( uri = userUriTransformer.createUserUri(newDid), webFinger = WebFinger.createFromDid(newDid), ), accessJwt = session.accessJwt, refreshJwt = session.refreshJwt, handle = session.handle.handle, did = session.did.did, didDoc = session.didDoc?.tryToJsonObject() ?: JsonObject.Empty, active = session.active, ) } @OptIn(ExperimentalTime::class) fun updateProfile( account: BlueskyLoggedAccount, profile: ProfileViewDetailed, ): BlueskyLoggedAccount { val newDid = profile.did.did return account.copy( user = convertToBlogAuthor(newDid, profile.handle.handle, profile), handle = profile.handle.handle, did = newDid, createAt = profile.createdAt?.let { Instant(it) }, ) } private fun JsonContent.tryToJsonObject(): JsonObject? { return bskyJson.encodeToJsonElement(JsonContent.serializer(), this) .takeIf { it is JsonObject }?.jsonObject } fun convertRelationship(viewerState: ViewerState): Relationships { return Relationships( following = viewerState.following?.atUri != null, followedBy = viewerState.followedBy?.atUri != null, blocking = viewerState.blocking?.atUri != null, blockedBy = viewerState.blockedBy == true, muting = viewerState.muted == true, requested = null, requestedBy = null, ) } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/adapter/BlueskyFeedsAdapter.kt ================================================ package com.zhangke.fread.bluesky.internal.adapter import app.bsky.actor.SavedFeed import app.bsky.feed.GeneratorView import app.bsky.graph.ListView import com.zhangke.fread.bluesky.internal.model.BlueskyFeeds class BlueskyFeedsAdapter( private val profileAdapter: BlueskyProfileAdapter, ) { fun convertToFeeds( savedFeed: SavedFeed, generator: GeneratorView, ): BlueskyFeeds.Feeds { return BlueskyFeeds.Feeds( uri = generator.uri.atUri, cid = generator.cid.cid, did = generator.did.did, pinned = savedFeed.pinned, displayName = generator.displayName, description = generator.description, avatar = generator.avatar?.uri, likeCount = generator.likeCount, likedRecord = generator.viewer?.like?.atUri, creator = profileAdapter.convertToProfile(generator.creator), ) } fun convertToFeeds( generator: GeneratorView, pinned: Boolean, ): BlueskyFeeds.Feeds { return BlueskyFeeds.Feeds( uri = generator.uri.atUri, cid = generator.cid.cid, did = generator.did.did, pinned = pinned, displayName = generator.displayName, description = generator.description, avatar = generator.avatar?.uri, likeCount = generator.likeCount, likedRecord = generator.viewer?.like?.atUri, creator = profileAdapter.convertToProfile(generator.creator), ) } fun convertToList( feed: SavedFeed, listView: ListView, ): BlueskyFeeds.List { return BlueskyFeeds.List( id = feed.id, uri = feed.value, name = listView.name, description = listView.description, avatar = listView.avatar?.uri, pinned = feed.pinned, ) } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/adapter/BlueskyNotificationAdapter.kt ================================================ package com.zhangke.fread.bluesky.internal.adapter import com.zhangke.fread.bluesky.internal.model.CompletedBskyNotification import com.zhangke.fread.bluesky.internal.utils.bskyJson import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.notification.StatusNotification import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.status.model.Status class BlueskyNotificationAdapter( private val accountAdapter: BlueskyAccountAdapter, private val statusAdapter: BlueskyStatusAdapter, ) { fun convert( notification: CompletedBskyNotification, locator: PlatformLocator, platform: BlogPlatform, ): StatusNotification { return notification.convertNotification(locator, platform) } private fun CompletedBskyNotification.convertNotification( locator: PlatformLocator, blogPlatform: BlogPlatform, ): StatusNotification { val author = accountAdapter.convertToBlogAuthor(this.author) return when (this.record) { is CompletedBskyNotification.Record.Like -> { StatusNotification.Like( id = this.cid, locator = locator, author = author, unread = !this.isRead, blog = statusAdapter.convertToBlog( post = this.record.post.record.bskyJson(), id = this.record.post.cid.cid, url = this.record.post.uri.atUri, platform = blogPlatform, author = accountAdapter.convertToBlogAuthor(this.record.post.author), ), createAt = this.record.createAt, ) } is CompletedBskyNotification.Record.Follow -> { StatusNotification.Follow( id = this.cid, author = author, locator = locator, unread = !this.isRead, createAt = this.record.createAt, ) } is CompletedBskyNotification.Record.Mention -> { StatusNotification.Mention( author = author, id = this.cid, unread = !this.isRead, status = statusAdapter.convertToUiState( locator = locator, status = Status.NewBlog( statusAdapter.convertToBlog( post = this.record.post, id = this.record.cid, url = this.record.uri, platform = blogPlatform, author = author, ) ), logged = true, isOwner = this.record.isOwner, ), ) } is CompletedBskyNotification.Record.Reply -> { StatusNotification.Reply( id = this.cid, author = author, unread = !this.isRead, reply = statusAdapter.convertToUiState( locator = locator, status = Status.NewBlog( statusAdapter.convertToBlog( post = this.record.reply, id = this.record.cid, url = this.record.uri, platform = blogPlatform, author = author, ) ), logged = true, isOwner = this.record.isOwner, ), ) } is CompletedBskyNotification.Record.Quote -> { StatusNotification.Quote( id = this.cid, unread = !this.isRead, author = author, quote = statusAdapter.convertToUiState( locator = locator, status = Status.NewBlog( statusAdapter.convertToBlog( post = this.record.quote, id = this.record.cid, url = this.record.uri, platform = blogPlatform, author = author, ) ), logged = true, isOwner = this.record.isOwner, ), ) } is CompletedBskyNotification.Record.Repost -> { StatusNotification.Repost( id = this.cid, author = author, locator = locator, unread = !this.isRead, blog = statusAdapter.convertToBlog( post = this.record.post.record.bskyJson(), id = this.record.post.cid.cid, url = this.record.post.uri.atUri, platform = blogPlatform, author = author, ), createAt = this.record.createAt, ) } is CompletedBskyNotification.Record.OnlyMessage -> { StatusNotification.Unknown( id = this.cid, locator = locator, unread = !this.isRead, message = this.record.message, createAt = this.record.createAt, ) } } } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/adapter/BlueskyProfileAdapter.kt ================================================ package com.zhangke.fread.bluesky.internal.adapter import app.bsky.actor.ProfileView import com.zhangke.fread.bluesky.internal.model.BlueskyProfile class BlueskyProfileAdapter() { fun convertToProfile(profile: ProfileView): BlueskyProfile { return BlueskyProfile( did = profile.did.did, handle = profile.handle.handle, displayName = profile.displayName, description = profile.description, avatar = profile.avatar?.uri, ) } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/adapter/BlueskyStatusAdapter.kt ================================================ package com.zhangke.fread.bluesky.internal.adapter import app.bsky.embed.AspectRatio import app.bsky.embed.ExternalViewExternal import app.bsky.embed.ImagesViewImage import app.bsky.embed.RecordViewRecord import app.bsky.embed.RecordViewRecordUnion import app.bsky.embed.RecordWithMediaViewMediaUnion import app.bsky.embed.VideoView import app.bsky.feed.FeedViewPost import app.bsky.feed.FeedViewPostReasonUnion import app.bsky.feed.Post import app.bsky.feed.PostView import app.bsky.feed.PostViewEmbedUnion import app.bsky.richtext.Facet import app.bsky.richtext.FacetFeatureUnion import com.zhangke.framework.datetime.Instant import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccount import com.zhangke.fread.bluesky.internal.utils.bskyJson import com.zhangke.fread.common.utils.formatDefault import com.zhangke.fread.status.author.BlogAuthor import com.zhangke.fread.status.blog.Blog import com.zhangke.fread.status.blog.BlogEmbed import com.zhangke.fread.status.blog.BlogMedia import com.zhangke.fread.status.blog.BlogMediaMeta import com.zhangke.fread.status.blog.BlogMediaType import com.zhangke.fread.status.model.BlogTranslationUiState import com.zhangke.fread.status.model.PlatformLocator import com.zhangke.fread.status.model.StatusUiState import com.zhangke.fread.status.model.StatusVisibility import com.zhangke.fread.status.platform.BlogPlatform import com.zhangke.fread.status.status.model.Status import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement class BlueskyStatusAdapter( private val accountAdapter: BlueskyAccountAdapter, ) { fun convert( postView: PostView, platform: BlogPlatform, pinned: Boolean = false, ): Status { return Status.NewBlog( blog = convertToBlog( postView = postView, platform = platform, pinned = pinned, ), ) } fun convert( feedViewPost: FeedViewPost, platform: BlogPlatform, ): Status { val feedsReason = feedViewPost.reason val pinned = feedsReason is FeedViewPostReasonUnion.ReasonPin val blog = convertToBlog( postView = feedViewPost.post, platform = platform, pinned = pinned, ) if (feedsReason is FeedViewPostReasonUnion.ReasonRepost) { val author = accountAdapter.convertToBlogAuthor(feedsReason.value.by) return Status.Reblog( id = blog.id, createAt = Instant(blog.createAt.instant), author = author, reblog = blog, ) } return Status.NewBlog(blog) } fun convertToUiState( locator: PlatformLocator, status: Status, logged: Boolean, isOwner: Boolean, ): StatusUiState { return StatusUiState( status = status, logged = logged, isOwner = isOwner, blogTranslationState = BlogTranslationUiState(support = false), locator = locator, ) } fun convertToUiState( locator: PlatformLocator, postView: PostView, platform: BlogPlatform, loggedAccount: BlueskyLoggedAccount?, pinned: Boolean = false, ): StatusUiState { val status = convert(postView, platform, pinned) return convertToUiState( locator = locator, status = status, logged = loggedAccount != null, isOwner = loggedAccount != null && postView.author.did.did == loggedAccount.did, ) } fun convertToUiState( locator: PlatformLocator, feedViewPost: FeedViewPost, platform: BlogPlatform, loggedAccount: BlueskyLoggedAccount?, ): StatusUiState { val status = convert( feedViewPost = feedViewPost, platform = platform, ) return convertToUiState( locator = locator, status = status, logged = loggedAccount != null, isOwner = loggedAccount != null && feedViewPost.post.author.did.did == loggedAccount.did, ) } private fun convertToBlog( postView: PostView, platform: BlogPlatform, pinned: Boolean, ): Blog { val post: Post = postView.record.bskyJson() return convertToBlog( post = post, id = postView.cid.cid, author = accountAdapter.convertToBlogAuthor(postView.author), url = postView.uri.atUri, replyCount = postView.replyCount, likeCount = postView.likeCount, repostCount = postView.repostCount, liked = postView.viewer?.like?.atUri.isNullOrEmpty().not(), forward = postView.viewer?.repost?.atUri.isNullOrEmpty().not(), mediaList = postView.embed?.let(::convertToMedia) ?: emptyList(), embedList = convertEmbed(postView.embed, platform), platform = platform, pinned = pinned, ) } private fun convertToBlog( recordView: RecordViewRecord, platform: BlogPlatform, ): Blog { val post: Post = bskyJson.decodeFromJsonElement(bskyJson.encodeToJsonElement(recordView.value)) return convertToBlog( post = post, id = recordView.cid.cid, author = accountAdapter.convertToBlogAuthor(recordView.author), url = recordView.uri.atUri, repostCount = recordView.repostCount, likeCount = recordView.likeCount, replyCount = recordView.replyCount, platform = platform, ) } fun convertToBlog( post: Post, id: String, author: BlogAuthor, url: String, platform: BlogPlatform, liked: Boolean = false, forward: Boolean = false, pinned: Boolean = false, repostCount: Long? = null, likeCount: Long? = null, replyCount: Long? = null, embedList: List = emptyList(), mediaList: List = emptyList(), ): Blog { val createAt = Instant(post.createdAt) return Blog( id = id, author = author, title = null, description = null, content = post.text, url = url, link = buildLink( url = url, handle = author.handle, ), createAt = createAt, formattedCreateAt = createAt.formatDefault(), like = Blog.Like( support = true, liked = liked, likedCount = likeCount ?: 0L, ), forward = Blog.Forward( support = true, forward = forward, forwardCount = repostCount ?: 0L, ), bookmark = Blog.Bookmark( support = false, ), reply = Blog.Reply( support = true, repliesCount = replyCount ?: 0L, ), quote = Blog.Quote(support = true, enabled = true), supportEdit = false, sensitive = false, spoilerText = "", isReply = post.reply != null, language = post.langs.firstOrNull()?.tag, platform = platform, mediaList = mediaList, emojis = emptyList(), mentions = emptyList(), tags = emptyList(), pinned = pinned, poll = null, facets = post.facets.map { it.convert() }, visibility = StatusVisibility.PUBLIC, embeds = embedList, supportTranslate = false, ) } private fun buildLink( url: String, handle: String, ): String { //https://bsky.app/profile/lotuscat.bsky.social/post/3llqkin45722p return buildString { append("https://bsky.app/profile/") append(handle.removePrefix("@")) append("/post/") append(url.substringAfterLast("/")) } } private fun convertToMedia(embedUnion: PostViewEmbedUnion): List { return when (embedUnion) { is PostViewEmbedUnion.ImagesView -> { embedUnion.value.images.map { it.toMedia() } } is PostViewEmbedUnion.VideoView -> { listOf(embedUnion.value.toMedia()) } is PostViewEmbedUnion.RecordWithMediaView -> { when (val media = embedUnion.value.media) { // ExternalView will be convert to link embed is RecordWithMediaViewMediaUnion.ExternalView -> emptyList() is RecordWithMediaViewMediaUnion.ImagesView -> { media.value.images.map { it.toMedia() } } is RecordWithMediaViewMediaUnion.VideoView -> { listOf(media.value.toMedia()) } is RecordWithMediaViewMediaUnion.Unknown -> emptyList() } } else -> emptyList() } } private fun ImagesViewImage.toMedia(): BlogMedia { return BlogMedia( id = this.fullsize.uri, url = this.fullsize.uri, type = BlogMediaType.IMAGE, previewUrl = this.thumb.uri, remoteUrl = null, description = this.alt, blurhash = null, meta = aspectRatio?.let(::buildImageMediaMeta), ) } private fun buildImageMediaMeta(aspectRatio: AspectRatio): BlogMediaMeta.ImageMeta { return BlogMediaMeta.ImageMeta( original = BlogMediaMeta.ImageMeta.LayoutMeta( width = aspectRatio.width, height = aspectRatio.height, size = null, aspect = aspectRatio.aspect, ), small = null, focus = null, ) } private fun VideoView.toMedia(): BlogMedia { return BlogMedia( id = this.playlist.uri, url = this.playlist.uri, type = BlogMediaType.VIDEO, previewUrl = this.thumbnail?.uri, remoteUrl = null, description = this.alt, blurhash = null, meta = aspectRatio?.let(::buildImageVideoMeta), ) } private fun buildImageVideoMeta(aspectRatio: AspectRatio): BlogMediaMeta.VideoMeta { return BlogMediaMeta.VideoMeta( length = null, duration = null, fps = null, size = null, width = aspectRatio.width, height = aspectRatio.height, aspect = aspectRatio.aspect, audioEncode = null, audioBitrate = null, audioChannels = null, original = BlogMediaMeta.VideoMeta.LayoutMeta( width = aspectRatio.width, height = aspectRatio.height, size = null, aspect = aspectRatio.aspect, frameRate = null, duration = null, bitrate = null, ), small = null, ) } private val AspectRatio.aspect: Float get() = (width.toDouble() / height.toDouble()).toFloat() private fun convertEmbed( embedUnion: PostViewEmbedUnion?, platform: BlogPlatform, ): List { if (embedUnion is PostViewEmbedUnion.ExternalView) { return listOf(embedUnion.value.external.toLinkEmbed()) } if (embedUnion is PostViewEmbedUnion.RecordWithMediaView) { val embeds = mutableListOf() val media = embedUnion.value.media if (media is RecordWithMediaViewMediaUnion.ExternalView) { // another type will be convert to media list in Blog embeds += media.value.external.toLinkEmbed() } val record = embedUnion.value.record.record if (record is RecordViewRecordUnion.ViewRecord) { embeds += record.toBlogEmbed(platform) } return embeds } if (embedUnion is PostViewEmbedUnion.RecordView) { val embedRecord = embedUnion.value.record if (embedRecord is RecordViewRecordUnion.ViewRecord) { return listOf(embedRecord.toBlogEmbed(platform)) } } return emptyList() } private fun RecordViewRecordUnion.ViewRecord.toBlogEmbed( platform: BlogPlatform, ): BlogEmbed { return BlogEmbed.Blog(convertToBlog(this.value, platform)) } private fun ExternalViewExternal.toLinkEmbed(): BlogEmbed.Link { return BlogEmbed.Link( url = this.uri.uri, title = this.title, description = this.description, image = this.thumb?.uri, video = false, ) } private fun Facet.convert(): com.zhangke.fread.status.model.Facet { return com.zhangke.fread.status.model.Facet( byteStart = this.index.byteStart, byteEnd = this.index.byteEnd, features = this.features.mapNotNull { feature -> when (feature) { is FacetFeatureUnion.Mention -> com.zhangke.fread.status.model.FacetFeatureUnion.Mention( did = feature.value.did.did, ) is FacetFeatureUnion.Link -> com.zhangke.fread.status.model.FacetFeatureUnion.Link( uri = feature.value.uri.uri, ) is FacetFeatureUnion.Tag -> com.zhangke.fread.status.model.FacetFeatureUnion.Tag( tag = feature.value.tag, ) is FacetFeatureUnion.Unknown -> null } } ) } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/client/BlueskyClient.kt ================================================ package com.zhangke.fread.bluesky.internal.client import app.bsky.actor.GetPreferencesResponse import app.bsky.actor.GetProfileQueryParams import app.bsky.actor.GetProfilesQueryParams import app.bsky.actor.GetProfilesResponse import app.bsky.actor.ProfileView import app.bsky.actor.ProfileViewDetailed import app.bsky.actor.PutPreferencesRequest import app.bsky.actor.SearchActorsQueryParams import app.bsky.actor.SearchActorsResponse import app.bsky.feed.GetActorFeedsQueryParams import app.bsky.feed.GetActorFeedsResponse import app.bsky.feed.GetActorLikesQueryParams import app.bsky.feed.GetActorLikesResponse import app.bsky.feed.GetAuthorFeedQueryParams import app.bsky.feed.GetAuthorFeedResponse import app.bsky.feed.GetFeedGeneratorsQueryParams import app.bsky.feed.GetFeedGeneratorsResponse import app.bsky.feed.GetFeedQueryParams import app.bsky.feed.GetFeedResponse import app.bsky.feed.GetLikesQueryParams import app.bsky.feed.GetListFeedQueryParams import app.bsky.feed.GetListFeedResponse import app.bsky.feed.GetPostThreadQueryParams import app.bsky.feed.GetPostThreadResponse import app.bsky.feed.GetPostsQueryParams import app.bsky.feed.GetPostsResponse import app.bsky.feed.GetRepostedByQueryParams import app.bsky.feed.GetSuggestedFeedsQueryParams import app.bsky.feed.GetSuggestedFeedsResponse import app.bsky.feed.GetTimelineQueryParams import app.bsky.feed.GetTimelineResponse import app.bsky.feed.SearchPostsQueryParams import app.bsky.feed.SearchPostsResponse import app.bsky.graph.GetBlocksQueryParams import app.bsky.graph.GetFollowersQueryParams import app.bsky.graph.GetFollowsQueryParams import app.bsky.graph.GetListQueryParams import app.bsky.graph.GetListResponse import app.bsky.graph.GetListsQueryParams import app.bsky.graph.GetListsResponse import app.bsky.graph.GetMutesQueryParams import app.bsky.graph.MuteActorRequest import app.bsky.graph.UnmuteActorRequest import app.bsky.notification.ListNotificationsQueryParams import app.bsky.notification.ListNotificationsResponse import app.bsky.notification.UpdateSeenRequest import app.bsky.unspecced.GetPopularFeedGeneratorsQueryParams import app.bsky.unspecced.GetPopularFeedGeneratorsResponse import com.atproto.repo.ApplyWritesRequest import com.atproto.repo.ApplyWritesResponse import com.atproto.repo.CreateRecordRequest import com.atproto.repo.CreateRecordResponse import com.atproto.repo.DeleteRecordRequest import com.atproto.repo.DeleteRecordResponse import com.atproto.repo.GetRecordQueryParams import com.atproto.repo.GetRecordResponse import com.atproto.repo.PutRecordRequest import com.atproto.repo.PutRecordResponse import com.atproto.repo.UploadBlobResponse import com.atproto.server.CreateSessionRequest import com.atproto.server.CreateSessionResponse import com.atproto.server.RefreshSessionResponse import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.utils.exceptionOrThrow import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccount import com.zhangke.fread.bluesky.internal.utils.toResult import com.zhangke.fread.status.model.PagedData import io.ktor.client.HttpClient import io.ktor.client.engine.HttpClientEngine import io.ktor.client.plugins.DefaultRequest import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logging import io.ktor.http.Url import kotlinx.serialization.json.Json import sh.christian.ozone.BlueskyApi import sh.christian.ozone.XrpcBlueskyApi import sh.christian.ozone.api.AtIdentifier import sh.christian.ozone.api.Did import sh.christian.ozone.api.Uri import sh.christian.ozone.api.response.AtpResponse class BlueskyClient( val baseUrl: FormalBaseUrl, private val engine: HttpClientEngine, val json: Json, val loggedAccountProvider: suspend () -> BlueskyLoggedAccount?, val newSessionUpdater: suspend (RefreshSessionResponse) -> Unit, val onLoginRequest: suspend () -> Unit, ) : BlueskyApi by XrpcBlueskyApi( createBlueskyHttpClient( engine, json, baseUrl.toString(), loggedAccountProvider, newSessionUpdater, onLoginRequest, ) ) { suspend fun createSessionCatching(request: CreateSessionRequest): Result { return runCatching { createSession(request) }.toResult() } suspend fun refreshSessionCatching(): Result { return kotlin.runCatching { refreshSession() }.toResult() } suspend fun getProfileCatching(request: GetProfileQueryParams): Result { return runCatching { getProfile(request) }.toResult() } suspend fun getProfileCatching(did: String): Result { return runCatching { getProfile(GetProfileQueryParams(Did(did))) }.toResult() } suspend fun getProfilesCatching(request: GetProfilesQueryParams): Result { return runCatching { getProfiles(request) }.toResult() } suspend fun getTimelineCatching(request: GetTimelineQueryParams): Result { return runCatching { getTimeline(request) }.toResult() } suspend fun getPreferencesCatching(): Result { return runCatching { getPreferences() }.toResult() } suspend fun putPreferencesCatching(request: PutPreferencesRequest): Result { return runCatching { putPreferences(request) }.toResult() } suspend fun getFeedGeneratorsCatching(params: GetFeedGeneratorsQueryParams): Result { return runCatching { getFeedGenerators(params) }.toResult() } suspend fun getFeedCatching(params: GetFeedQueryParams): Result { return runCatching { getFeed(params) }.toResult() } suspend fun getPopularFeedGeneratorsUnspeccedCatching(params: GetPopularFeedGeneratorsQueryParams): Result { return runCatching { getPopularFeedGeneratorsUnspecced(params) }.toResult() } suspend fun getActorFeedsCatching(request: GetActorFeedsQueryParams): Result { return runCatching { getActorFeeds(request) }.toResult() } suspend fun getAuthorFeedCatching(request: GetAuthorFeedQueryParams): Result { return runCatching { getAuthorFeed(request) }.toResult() } suspend fun getSuggestedFeedsCatching(params: GetSuggestedFeedsQueryParams): Result { return runCatching { getSuggestedFeeds(params) }.toResult() } suspend fun getListsCatching(request: GetListsQueryParams): Result { return runCatching { getLists(request) }.toResult() } suspend fun getListCatching(params: GetListQueryParams): Result { return runCatching { getList(params) }.toResult() } suspend fun getListFeedCatching(params: GetListFeedQueryParams): Result { return runCatching { getListFeed(params) }.toResult() } suspend fun getRecordCatching(params: GetRecordQueryParams): Result { return runCatching { getRecord(params) }.toResult() } suspend fun createRecordCatching(params: CreateRecordRequest): Result { return runCatching { createRecord(params) }.toResult() } suspend fun putRecordCatching(params: PutRecordRequest): Result { return runCatching { putRecord(params) }.toResult() } suspend fun deleteRecordCatching(params: DeleteRecordRequest): Result { return runCatching { deleteRecord(params) }.toResult() } suspend fun getPostThreadCatching(params: GetPostThreadQueryParams): Result { return runCatching { getPostThread(params) }.toResult() } suspend fun getPostsCatching(params: GetPostsQueryParams): Result { return runCatching { getPosts(params) }.toResult() } suspend fun searchPostsCatching(params: SearchPostsQueryParams): Result { return runCatching { searchPosts(params) }.toResult() } suspend fun searchActorsCatching(params: SearchActorsQueryParams): Result { return runCatching { searchActors(params) }.toResult() } suspend fun getActorLikesCatching(params: GetActorLikesQueryParams): Result { return runCatching { getActorLikes(params) }.toResult() } suspend fun listNotificationsCatching(params: ListNotificationsQueryParams): Result { return runCatching { listNotifications(params) }.toResult() } suspend fun updateSeenCatching(request: UpdateSeenRequest): Result { return runCatching { updateSeen(request) }.toResult() } suspend fun muteActorCatching(actor: AtIdentifier): Result { return runCatching { muteActor(MuteActorRequest(actor)) }.toResult() } suspend fun unmuteActorCatching(actor: AtIdentifier): Result { return runCatching { unmuteActor(UnmuteActorRequest(actor)) }.toResult() } suspend fun getFollowsCatching(params: GetFollowsQueryParams): Result> { return runCatching { getFollows(params) }.toResult() .map { PagedData(list = it.follows, cursor = it.cursor) } } suspend fun getFollowersCatching(params: GetFollowersQueryParams): Result> { return runCatching { getFollowers(params) }.toResult() .map { PagedData(list = it.followers, cursor = it.cursor) } } suspend fun getMutesCatching(params: GetMutesQueryParams): Result> { return runCatching { getMutes(params) }.toResult() .map { PagedData(list = it.mutes, cursor = it.cursor) } } suspend fun getBlocksCatching(params: GetBlocksQueryParams): Result> { return runCatching { getBlocks(params) }.toResult() .map { PagedData(list = it.blocks, cursor = it.cursor) } } suspend fun getLikesCatching(params: GetLikesQueryParams): Result> { return runCatching { getLikes(params) }.toResult() .map { data -> PagedData(list = data.likes.map { it.actor }, cursor = data.cursor) } } suspend fun getRepostedCatching(params: GetRepostedByQueryParams): Result> { return runCatching { getRepostedBy(params) }.toResult() .map { data -> PagedData(list = data.repostedBy, cursor = data.cursor) } } suspend fun uploadBlobCatching(data: ByteArray): Result { return runCatching { uploadBlob(data) }.toResult() } suspend fun applyWritesCatching(request: ApplyWritesRequest): Result { return runCatching { applyWrites(request) }.toResult() } suspend fun searchPostsByUri(uri: String): Result { return runCatching { searchPosts(SearchPostsQueryParams(q = "", url = Uri(uri))) }.toResult() } private fun Result>.toResult(): Result { if (this.isFailure) return Result.failure(this.exceptionOrThrow()) return this.getOrThrow().toResult() } } private fun createBlueskyHttpClient( engine: HttpClientEngine, json: Json, baseUrl: String, accountProvider: suspend () -> BlueskyLoggedAccount?, newSessionUpdater: suspend (RefreshSessionResponse) -> Unit, onLoginRequest: suspend () -> Unit, ): HttpClient { return HttpClient(engine) { install(Logging) { level = LogLevel.ALL } install(DefaultRequest) { val hostUrl = Url(baseUrl) url.protocol = hostUrl.protocol url.host = hostUrl.host url.port = hostUrl.port } install(XrpcAuthPlugin) { this.json = json this.accountProvider = accountProvider this.newSessionUpdater = newSessionUpdater this.onLoginRequest = onLoginRequest } install(AtProtoProxyPlugin) expectSuccess = false } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/client/BlueskyClientManager.kt ================================================ package com.zhangke.fread.bluesky.internal.client import com.atproto.server.RefreshSessionResponse import com.zhangke.framework.architect.coroutines.ApplicationScope import com.zhangke.framework.architect.http.createHttpClientEngine import com.zhangke.framework.architect.json.globalJson import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.utils.throwInDebug import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccount import com.zhangke.fread.bluesky.internal.adapter.BlueskyAccountAdapter import com.zhangke.fread.bluesky.internal.repo.BlueskyLoggedAccountRepo import com.zhangke.fread.status.model.PlatformLocator import kotlinx.coroutines.launch class BlueskyClientManager( private val loggedAccountRepo: BlueskyLoggedAccountRepo, private val accountAdapter: BlueskyAccountAdapter, ) { private val cachedClient = mutableMapOf() private val cachedAccount = mutableMapOf() private val httpClientEngine by lazy { createHttpClientEngine() } init { ApplicationScope.launch { loggedAccountRepo.queryAllFlow().collect { clearCache() } } } fun clearCache() { cachedClient.clear() cachedAccount.clear() } fun getClient(locator: PlatformLocator): BlueskyClient { cachedClient[locator]?.let { return it } val loggedAccountProvider = suspend { getLoggedAccount(locator) } return createClient(locator, loggedAccountProvider).also { cachedClient[locator] = it } } fun getClientNoAccount(baseUrl: FormalBaseUrl): BlueskyClient { return BlueskyClient( baseUrl = baseUrl, engine = httpClientEngine, json = globalJson, loggedAccountProvider = { null }, newSessionUpdater = { }, onLoginRequest = {}, ) } private fun createClient( locator: PlatformLocator, loggedAccountProvider: suspend () -> BlueskyLoggedAccount?, ): BlueskyClient { return BlueskyClient( baseUrl = locator.baseUrl, engine = httpClientEngine, json = globalJson, loggedAccountProvider = loggedAccountProvider, newSessionUpdater = { updateNewSession(locator, it) }, onLoginRequest = { // GlobalScreenNavigation.navigate(AddBlueskyContentScreen(role.baseUrl!!, true)) }, ) } suspend fun updateNewSession(locator: PlatformLocator, session: RefreshSessionResponse) { cachedAccount.clear() val account = getLoggedAccount(locator) ?: return val newAccount = accountAdapter.updateNewSession(account, session) loggedAccountRepo.updateAccount(account, newAccount) } private suspend fun getLoggedAccount(locator: PlatformLocator): BlueskyLoggedAccount? { cachedAccount[locator]?.let { return it } if (locator.accountUri != null) { return loggedAccountRepo.queryByUri(locator.accountUri.toString()) ?.also { cachedAccount[locator] = it } } val thisPlatformAccounts = loggedAccountRepo.queryAll() .filter { it.platform.baseUrl.equalsDomain(locator.baseUrl) } if (thisPlatformAccounts.size > 1) { throwInDebug("Multiple accounts found for base URL: ${locator.baseUrl}") return null } return thisPlatformAccounts.firstOrNull() } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/client/BlueskyResponseUtils.kt ================================================ package com.zhangke.fread.bluesky.internal.client import sh.christian.ozone.api.response.AtpErrorDescription import sh.christian.ozone.api.response.AtpResponse private fun AtpResponse.toResult(): Result { return when (this) { is AtpResponse.Success -> Result.success(this.response) is AtpResponse.Failure -> Result.failure(BlueskyApiException.fromResponse(this)) } } data class BlueskyApiException( val response: Any?, val error: AtpErrorDescription?, val headers: Map, ) : RuntimeException() { companion object { fun fromResponse(failure: AtpResponse.Failure): BlueskyApiException { return BlueskyApiException( response = failure.response, error = failure.error, headers = failure.headers, ) } } } val AtpErrorDescription.expired: Boolean get() = error == "ExpiredToken" ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/client/BskyCollections.kt ================================================ package com.zhangke.fread.bluesky.internal.client import sh.christian.ozone.api.Nsid object BskyCollections { val feedLike = Nsid("app.bsky.feed.like") val feedRepost = Nsid("app.bsky.feed.repost") val feedPost = Nsid("app.bsky.feed.post") val profile = Nsid("app.bsky.actor.profile") val follow = Nsid("app.bsky.graph.follow") val block = Nsid("app.bsky.graph.block") val postGate = Nsid("app.bsky.feed.postgate") val threadGate = Nsid("app.bsky.feed.threadgate") } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/client/BskyHttpPlugin.kt ================================================ package com.zhangke.fread.bluesky.internal.client import com.atproto.server.RefreshSessionResponse import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccount import io.ktor.client.HttpClient import io.ktor.client.call.HttpClientCall import io.ktor.client.call.body import io.ktor.client.call.save import io.ktor.client.plugins.HttpClientPlugin import io.ktor.client.plugins.HttpSend import io.ktor.client.plugins.plugin import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.HttpRequestPipeline import io.ktor.client.request.bearerAuth import io.ktor.client.request.post import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpHeaders.Authorization import io.ktor.http.HttpStatusCode.Companion.BadRequest import io.ktor.util.AttributeKey import kotlinx.serialization.json.Json import sh.christian.ozone.api.response.AtpErrorDescription import sh.christian.ozone.api.response.StatusCode internal class AtProtoProxyPlugin { companion object : HttpClientPlugin { override val key = AttributeKey("AtprotoProxyPlugin") override fun prepare(block: Unit.() -> Unit): AtProtoProxyPlugin = AtProtoProxyPlugin() override fun install( plugin: AtProtoProxyPlugin, scope: HttpClient, ) { scope.requestPipeline.intercept(HttpRequestPipeline.State) { if (context.url.pathSegments .lastOrNull() ?.startsWith("chat.bsky.convo.") == true ) { context.headers["Atproto-Proxy"] = "did:web:api.bsky.chat#bsky_chat" } } } } } internal class XrpcAuthPlugin( private val json: Json, private val accountProvider: suspend () -> BlueskyLoggedAccount?, private val newSessionUpdater: suspend (RefreshSessionResponse) -> Unit, private val onLoginRequest: suspend () -> Unit, ) { class Config( var json: Json? = null, var accountProvider: suspend () -> BlueskyLoggedAccount? = { null }, var newSessionUpdater: suspend (RefreshSessionResponse) -> Unit = {}, var onLoginRequest: suspend () -> Unit = {}, ) companion object : HttpClientPlugin { private const val REFRESH_TOKEN_METHOD = "com.atproto.server.refreshSession" private const val REFRESH_TOKEN_PATH = "/xrpc/$REFRESH_TOKEN_METHOD" override val key = AttributeKey("XrpcAuthPlugin") override fun prepare(block: Config.() -> Unit): XrpcAuthPlugin { val config = Config().apply(block) return XrpcAuthPlugin( config.json!!, config.accountProvider, config.newSessionUpdater, config.onLoginRequest, ) } override fun install(plugin: XrpcAuthPlugin, scope: HttpClient) { scope.plugin(HttpSend).intercept { context -> if (!context.headers.contains(Authorization)) { val account = plugin.accountProvider() if (account != null) { if (context.isRefreshTokenRequest) { context.bearerAuth(account.refreshJwt) } else { context.bearerAuth(account.accessJwt) } } } var result: HttpClientCall = execute(context) if (result.response.status != BadRequest) { return@intercept result } result = result.save() val response = runCatching { plugin.json.decodeFromString(result.response.bodyAsText()) } if (response.getOrNull()?.expired == true) { if (context.isRefreshTokenRequest) { plugin.onLoginRequest() } else { val account = plugin.accountProvider() if (account != null) { refreshToken(scope, account.refreshJwt)?.let { refreshedResponse -> plugin.newSessionUpdater(refreshedResponse) context.headers.remove(Authorization) context.bearerAuth(refreshedResponse.accessJwt) result = execute(context) } } } } result } } private val HttpRequestBuilder.isRefreshTokenRequest: Boolean get() = url.pathSegments.contains(REFRESH_TOKEN_METHOD) private suspend fun refreshToken( scope: HttpClient, refreshToken: String, ): RefreshSessionResponse? { return runCatching { val response = scope.post(REFRESH_TOKEN_PATH) { bearerAuth(refreshToken) } if (StatusCode.fromCode(response.status.value) == StatusCode.Okay) { response.body() } else { null } }.getOrNull() } } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/client/records.kt ================================================ package com.zhangke.fread.bluesky.internal.client import app.bsky.feed.Like import app.bsky.feed.Repost import app.bsky.graph.Block import app.bsky.graph.Follow import com.atproto.repo.StrongRef import com.zhangke.fread.bluesky.internal.utils.bskyJson import kotlinx.datetime.Instant import sh.christian.ozone.api.Did import sh.christian.ozone.api.model.JsonContent import kotlinx.datetime.Clock internal fun likeRecord( subject: StrongRef, createdAt: Instant = Clock.System.now(), ): JsonContent { return Like( subject = subject, createdAt = createdAt, ).bskyJson() } internal fun repostRecord( subject: StrongRef, createdAt: Instant = Clock.System.now(), ): JsonContent { return Repost( subject = subject, createdAt = createdAt, ).bskyJson() } internal fun followRecord( did: String, createdAt: Instant = Clock.System.now(), ): JsonContent { return Follow( subject = Did(did), createdAt = createdAt, ).bskyJson() } internal fun blockRecord( did: String, createdAt: Instant = Clock.System.now(), ): JsonContent { return Block( subject = Did(did), createdAt = createdAt, ).bskyJson() } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/client/rkeys.kt ================================================ package com.zhangke.fread.bluesky.internal.client import com.zhangke.fread.status.status.model.Status import sh.christian.ozone.api.RKey internal val Status.rkey: RKey get() = intrinsicBlog.url.adjustToRkey() internal fun String.adjustToRkey(): RKey = RKey(this.substringAfterLast("/")) internal val selfRkey: RKey = RKey("self") ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/composable/BlueskyFeedsUi.kt ================================================ package com.zhangke.fread.bluesky.internal.composable import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ListAlt import androidx.compose.material.icons.filled.Add import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.seiko.imageloader.model.ImageAction import com.seiko.imageloader.model.ImageRequest import com.seiko.imageloader.rememberImageActionPainter import com.seiko.imageloader.ui.AutoSizeBox import com.zhangke.framework.composable.freadPlaceholder import com.zhangke.framework.utils.formatToHumanReadable import com.zhangke.fread.bluesky.internal.model.BlueskyFeeds import com.zhangke.fread.localization.LocalizedString import com.zhangke.fread.statusui.ic_drag_indicator import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @Composable fun BlueskyFollowingFeeds( modifier: Modifier, feeds: BlueskyFeeds, onFeedsClick: (BlueskyFeeds) -> Unit, ) { Row( modifier = modifier.clickable { onFeedsClick(feeds) } .padding(start = 16.dp, end = 8.dp, top = 16.dp, bottom = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { FeedsAvatar(feeds.avatar, Modifier) Column( modifier = Modifier.weight(1F).padding(horizontal = 16.dp), ) { Text( text = feeds.displayName(), style = MaterialTheme.typography.titleMedium, maxLines = 1, textAlign = TextAlign.Start, overflow = TextOverflow.Ellipsis, ) val subtitle = buildFeedsSubtitle(feeds) if (!subtitle.isNullOrEmpty()) { Text( modifier = Modifier.padding(top = 1.dp), text = subtitle, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } Icon( modifier = Modifier .align(Alignment.CenterVertically) .size(24.dp) .alpha(0.7F) .padding(2.dp), painter = painterResource(com.zhangke.fread.statusui.Res.drawable.ic_drag_indicator), contentDescription = "Drag for reorder Content Config", ) } } @Composable private fun buildFeedsSubtitle(feeds: BlueskyFeeds): String? { if (feeds !is BlueskyFeeds.Feeds) return null return stringResource( LocalizedString.bsky_feeds_item_subtitle, feeds.creator.prettyHandle, (feeds.likeCount ?: 0).formatToHumanReadable(), ) } @Composable fun BlueskyExploringFeeds( modifier: Modifier, feeds: BlueskyFeeds, loading: Boolean = false, onFeedsClick: (BlueskyFeeds) -> Unit, onAddClick: ((BlueskyFeeds) -> Unit)? = null, ) { Column( modifier = modifier.clickable { onFeedsClick(feeds) }.padding(horizontal = 16.dp), ) { Spacer(modifier = Modifier.size(8.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { FeedsAvatar(feeds.avatar, Modifier) Column( modifier = Modifier.weight(1F).padding(horizontal = 16.dp), verticalArrangement = Arrangement.Center, ) { Text( modifier = Modifier.fillMaxWidth(), text = feeds.displayName(), style = MaterialTheme.typography.titleMedium, maxLines = 1, textAlign = TextAlign.Start, overflow = TextOverflow.Ellipsis, ) if (!feeds.creatorName.isNullOrEmpty()) { Text( modifier = Modifier.fillMaxWidth().padding(top = 2.dp), text = stringResource( LocalizedString.bsky_feeds_explorer_creator_label, feeds.creatorName!!, ), style = MaterialTheme.typography.labelMedium, maxLines = 1, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6F), textAlign = TextAlign.Start, overflow = TextOverflow.Ellipsis, ) } } if (!feeds.pinned && onAddClick != null && !loading) { IconButton(onClick = { onAddClick(feeds) }) { Icon( imageVector = Icons.Default.Add, contentDescription = "Add Feeds", ) } } else if (loading) { CircularProgressIndicator(modifier = Modifier.size(18.dp)) } } if (feeds.description != null) { Text( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), text = feeds.description!!, style = MaterialTheme.typography.bodyMedium, maxLines = 8, textAlign = TextAlign.Start, overflow = TextOverflow.Ellipsis, ) } Text( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), text = stringResource( LocalizedString.bsky_feeds_explorer_liked_by, (feeds.likeCount ?: 0L).formatToHumanReadable(), ), style = MaterialTheme.typography.labelMedium, maxLines = 1, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Start, overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.size(8.dp)) } } @Composable internal fun FeedsAvatar( url: String?, modifier: Modifier, ) { if (url.isNullOrEmpty()) { Icon( modifier = modifier .size(42.dp) .clip(RoundedCornerShape(6.dp)) .background(MaterialTheme.colorScheme.primary), imageVector = Icons.AutoMirrored.Filled.ListAlt, tint = Color.White, contentDescription = "Avatar", ) } else { AutoSizeBox( modifier = modifier, request = remember(url) { ImageRequest(url) }, ) { action -> Image( painter = rememberImageActionPainter(action), contentDescription = "Avatar", modifier = Modifier .size(42.dp) .clip(RoundedCornerShape(6.dp)) .freadPlaceholder(action !is ImageAction.Success), ) } } } private val BlueskyFeeds.avatar: String? get() { return when (this) { is BlueskyFeeds.Feeds -> avatar is BlueskyFeeds.List -> avatar else -> null } } private val BlueskyFeeds.description: String? get() { return when (this) { is BlueskyFeeds.Feeds -> description is BlueskyFeeds.List -> description else -> null } } private val BlueskyFeeds.likeCount: Long? get() { return when (this) { is BlueskyFeeds.Feeds -> likeCount else -> null } } private val BlueskyFeeds.creatorName: String? get() { return when (this) { is BlueskyFeeds.Feeds -> creator.displayName else -> null } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/composable/DetailTopBar.kt ================================================ package com.zhangke.fread.bluesky.internal.composable import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.statusBars import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.sp import com.zhangke.framework.composable.Toolbar import com.zhangke.framework.utils.BlendColorUtils @OptIn(ExperimentalMaterial3Api::class) @Composable fun DetailTopBar( progress: Float, title: String, onBackClick: () -> Unit, actions: @Composable RowScope.() -> Unit, ) { val topBarContainerColor = MaterialTheme.colorScheme.surface.copy(progress) val onTopBarColor = BlendColorUtils.blend( fraction = progress, startColor = MaterialTheme.colorScheme.inverseOnSurface, endColor = MaterialTheme.colorScheme.onSurface, ) TopAppBar( title = { if (progress >= 1F) { Text( modifier = Modifier, text = title, fontSize = 22.sp, maxLines = 1, ) } }, navigationIcon = { Toolbar.BackButton(onBackClick = onBackClick) }, windowInsets = WindowInsets.statusBars, colors = TopAppBarDefaults.topAppBarColors( containerColor = topBarContainerColor, navigationIconContentColor = onTopBarColor, actionIconContentColor = onTopBarColor, ), actions = actions, ) } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/content/BlueskyContent.kt ================================================ package com.zhangke.fread.bluesky.internal.content import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.zhangke.framework.architect.json.JsonModuleBuilder import com.zhangke.framework.network.FormalBaseUrl import com.zhangke.framework.security.Md5 import com.zhangke.fread.bluesky.internal.model.BlueskyFeeds import com.zhangke.fread.commonbiz.bluesky_logo import com.zhangke.fread.status.account.LoggedAccount import com.zhangke.fread.status.model.FreadContent import com.zhangke.fread.status.uri.FormalUri import com.zhangke.krouter.annotation.Service import kotlinx.serialization.Serializable import kotlinx.serialization.modules.SerializersModuleBuilder import kotlinx.serialization.serializer import org.jetbrains.compose.resources.painterResource @Service class BlueskyContentJsonModuleBuilder : JsonModuleBuilder { override fun SerializersModuleBuilder.buildSerializersModule() { polymorphic( baseClass = FreadContent::class, actualClass = BlueskyContent::class, actualSerializer = serializer(), ) } } @Serializable data class BlueskyContent( override val name: String, override val order: Int, val baseUrl: FormalBaseUrl, val feedsList: List, override val accountUri: FormalUri? = null, ) : FreadContent { private val _id: String by lazy { if (accountUri == null) { Md5.md5(baseUrl.toString()) } else { Md5.md5(baseUrl.toString() + accountUri.toString()) } } override val id: String get() = _id override fun newOrder(newOrder: Int): FreadContent { return copy(order = newOrder) } @Composable override fun Subtitle(account: LoggedAccount?) { Row(verticalAlignment = Alignment.CenterVertically) { Image( modifier = Modifier.size(14.dp), painter = painterResource(com.zhangke.fread.commonbiz.Res.drawable.bluesky_logo), contentDescription = null, ) Text( modifier = Modifier .padding(start = 4.dp) .align(Alignment.Bottom), text = account?.prettyHandle ?: baseUrl.host, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/content/BlueskyContentManager.kt ================================================ package com.zhangke.fread.bluesky.internal.content import com.zhangke.fread.bluesky.internal.screen.add.AddBlueskyContentScreenNavKey import com.zhangke.fread.status.content.AddContentAction import com.zhangke.fread.status.content.IContentManager import com.zhangke.fread.status.model.ContentConfig import com.zhangke.fread.status.model.FreadContent import com.zhangke.fread.status.model.notBluesky import com.zhangke.fread.status.platform.BlogPlatform class BlueskyContentManager() : IContentManager { override suspend fun addContent( platform: BlogPlatform, action: AddContentAction ) { if (platform.protocol.notBluesky) return action.onFinishPage() action.onOpenNewPage(AddBlueskyContentScreenNavKey(baseUrl = platform.baseUrl)) } override fun restoreContent(config: ContentConfig): FreadContent? { // Bluesky does not container any old content data return null } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/db/BlueskyLoggedAccountDatabase.kt ================================================ package com.zhangke.fread.bluesky.internal.db import androidx.room.Dao import androidx.room.Database import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.RoomDatabase import androidx.room.TypeConverters import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccount import com.zhangke.fread.bluesky.internal.db.converter.BlueskyLoggedAccountConverter import kotlinx.coroutines.flow.Flow private const val TABLE_NAME = "logged_accounts" private const val DB_VERSION = 1 @Entity(tableName = TABLE_NAME) data class BlueskyLoggedAccountEntity( @PrimaryKey val uri: String, val account: BlueskyLoggedAccount, val addedTimestamp: Long, ) @Dao interface BlueskyLoggedAccountDao { @Query("SELECT * FROM $TABLE_NAME ORDER BY addedTimestamp") fun queryAllFlow(): Flow> @Query("SELECT * FROM $TABLE_NAME WHERE uri=:uri") suspend fun queryByUri(uri: String): BlueskyLoggedAccountEntity? @Query("SELECT * FROM $TABLE_NAME ORDER BY addedTimestamp") suspend fun queryAll(): List @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(account: BlueskyLoggedAccountEntity) @Query("DELETE FROM $TABLE_NAME WHERE uri=:uri") suspend fun deleteByUri(uri: String) } @TypeConverters(BlueskyLoggedAccountConverter::class) @Database( entities = [ BlueskyLoggedAccountEntity::class, ], version = DB_VERSION, exportSchema = false, ) abstract class BlueskyLoggedAccountDatabase : RoomDatabase() { abstract fun getDao(): BlueskyLoggedAccountDao companion object { internal const val DB_NAME = "bluesky_logged_accounts.db" } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/db/converter/BlueskyLoggedAccountConverter.kt ================================================ package com.zhangke.fread.bluesky.internal.db.converter import androidx.room.TypeConverter import com.zhangke.framework.architect.json.fromJson import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccount import com.zhangke.fread.bluesky.internal.utils.bskyJson import kotlinx.serialization.encodeToString class BlueskyLoggedAccountConverter { @TypeConverter fun fromAccount(account: BlueskyLoggedAccount): String { return bskyJson.encodeToString(account) } @TypeConverter fun toAccount(text: String): BlueskyLoggedAccount { return bskyJson.fromJson(text) } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/db/converter/GeneratorViewConverter.kt ================================================ package com.zhangke.fread.bluesky.internal.db.converter import androidx.room.TypeConverter import app.bsky.feed.GeneratorView import com.zhangke.framework.architect.json.fromJson import com.zhangke.fread.bluesky.internal.utils.bskyJson import kotlinx.serialization.encodeToString class GeneratorViewConverter { @TypeConverter fun fromGeneratorView(generator: GeneratorView): String { return bskyJson.encodeToString(generator) } @TypeConverter fun toGeneratorView(text: String): GeneratorView { return bskyJson.fromJson(text) } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/migrate/BlueskyContentMigrator.kt ================================================ package com.zhangke.fread.bluesky.internal.migrate import com.zhangke.fread.bluesky.internal.account.BlueskyLoggedAccountManager import com.zhangke.fread.bluesky.internal.content.BlueskyContent import com.zhangke.fread.common.content.FreadContentRepo class BlueskyContentMigrator( private val freadContentRepo: FreadContentRepo, private val accountManager: BlueskyLoggedAccountManager, ) { suspend fun migrate() { val allContent = freadContentRepo.getAllOldContents().filter { it.second is BlueskyContent } if (allContent.isEmpty()) return val allAccounts = accountManager.getAllAccount() allContent.map { it.second as BlueskyContent } .map { content -> if (content.accountUri != null) { content } else { val account = allAccounts.firstOrNull { it.platform.baseUrl == content.baseUrl } content.copy(accountUri = account?.uri) } }.let { freadContentRepo.insertAll(it) } for (content in allContent) { freadContentRepo.deleteOldContents(content.first) } } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/model/BlueskyFeeds.kt ================================================ package com.zhangke.fread.bluesky.internal.model import androidx.compose.runtime.Composable import com.zhangke.fread.localization.LocalizedString import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.stringResource /** * Feeds 一词包含两个含义,一是广义上的 Feeds 信息流。 * 二是 Bluesky 中的 Feeds,Bluesky 中的 Feeds 是由别人创建的,由 FeedsGenerator 生成的 Feeds。 * BlueskyFeeds 类中的 Feeds 是广义上的 Feeds,包含着 FeedsGenerator 生成的 Feeds, * 也包含 Following Timeline 和 用户自己创建的 List。 */ @Serializable sealed class BlueskyFeeds { /** * pinned to Home Screen */ abstract val pinned: Boolean abstract val id: String @Composable abstract fun displayName(): String @Serializable data class FollowingTimeline( override val pinned: Boolean, ) : BlueskyFeeds() { override val id: String get() = "FollowingTimeline" @Composable override fun displayName(): String { return stringResource(LocalizedString.bsky_feeds_following_name) } } @Serializable data class Feeds( val uri: String, val cid: String, val did: String, override val pinned: Boolean, val displayName: String, val description: String? = null, val avatar: String? = null, val likeCount: Long? = null, val likedRecord: String? = null, val creator: BlueskyProfile, ) : BlueskyFeeds() { val liked: Boolean get() = !likedRecord.isNullOrEmpty() override val id: String get() = cid @Composable override fun displayName(): String { return displayName } } @Serializable data class List( override val id: String, val uri: String, val name: String, val description: String? = null, val avatar: String? = null, override val pinned: Boolean, ) : BlueskyFeeds() { @Composable override fun displayName(): String { return name } } @Serializable data class Hashtags( val hashtag: String, override val pinned: Boolean = false, ) : BlueskyFeeds() { override val id: String get() = hashtag @Composable override fun displayName() = hashtag } @Serializable data class UserPosts( val did: String, override val pinned: Boolean = false, ) : BlueskyFeeds() { override val id: String get() = "$did/posts" @Composable override fun displayName(): String { return stringResource(LocalizedString.bsky_feeds_user_posts) } } @Serializable data class UserReplies( val did: String, override val pinned: Boolean = false, ) : BlueskyFeeds() { override val id: String get() = "$did/replies" @Composable override fun displayName(): String { return stringResource(LocalizedString.bsky_feeds_user_replies) } } @Serializable data class UserMedias( val did: String?, override val pinned: Boolean = false, ) : BlueskyFeeds() { override val id: String get() = "$did/medias" @Composable override fun displayName(): String { return stringResource(LocalizedString.bsky_feeds_user_medias) } } @Serializable data class UserLikes( val did: String? = null, override val pinned: Boolean = false, ) : BlueskyFeeds() { override val id: String get() = "$did/likes" @Composable override fun displayName(): String { return stringResource(LocalizedString.bsky_feeds_user_likes) } } } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/model/BlueskyProfile.kt ================================================ package com.zhangke.fread.bluesky.internal.model import kotlinx.serialization.Serializable @Serializable data class BlueskyProfile( val did: String, val handle: String, val displayName: String?, val description: String?, val avatar: String?, ){ val prettyHandle: String = if (handle.startsWith('@')) handle else "@$handle" } ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/model/BskyPagingFeeds.kt ================================================ package com.zhangke.fread.bluesky.internal.model import com.zhangke.fread.status.model.StatusUiState data class BskyPagingFeeds( val cursor: String?, val feeds: List, ) ================================================ FILE: plugins/bluesky/src/commonMain/kotlin/com/zhangke/fread/bluesky/internal/model/CompletedBskyNotification.kt ================================================ package com.zhangke.fread.bluesky.internal.model import app.bsky.actor.ProfileView import app.bsky.feed.Post import app.bsky.feed.PostView import com.atproto.label.Label import com.zhangke.framework.datetime.Instant import sh.christian.ozone.api.AtUri data class PagedCompletedBskyNotifications( val cursor: String? = null, val notifications: List, val priority: Boolean? = null, val seenAt: Instant? = null, ) data class CompletedBskyNotification( val uri: String, val cid: String, val author: ProfileView, val reasonSubject: AtUri? = null, val record: Record, val isRead: Boolean, val indexedAt: Instant, val labels: List